PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.13.1
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.13.1
3.13.1 3.13.0 3.12.13 3.12.12 3.12.11 3.12.10 3.12.9 3.12.8 3.12.7 3.12.6 3.12.5 3.12.4 3.12.3 3.12.1 3.12.2 3.12.0 3.11.1 3.11.0 3.10.9 3.10.8 3.10.7 3.10.6 2.8.16 2.8.17 2.8.18 All 259 releases
wpfunnels / includes / core / MCP / Tools / FunnelTools.php

FunnelTools.php in WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell 3.13.1, at includes/core/MCP/Tools/FunnelTools.php

884 lines 31.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * FunnelTools — funnel-level abilities.
4 *
5 * @package WPFunnels\MCP
6 * @since 3.13.0
7 */
8
9 namespace WPFunnels\MCP\Tools;
10
11 defined( 'ABSPATH' ) || exit;
12
13 use WPFunnels\Export\Wpfnl_Export;
14 use WPFunnels\MCP\Helpers\MCPHelper;
15 use WPFunnels\Modules\Admin\Funnel\Module as FunnelAdminModule;
16 use WPFunnels\Wpfnl;
17 use WPFunnels\Wpfnl_functions;
18
19 /**
20 * Class FunnelTools
21 */
22 class FunnelTools {
23
24 /**
25 * Funnel types this plugin understands.
26 */
27 private const TYPES = [ 'wc', 'lms', 'lead' ];
28
29 /**
30 * Ability definitions for this domain.
31 *
32 * @return array
33 */
34 public static function definitions() {
35 return [
36 'wpfunnels/list-funnels' => [
37 'label' => __( 'List Funnels', 'wpfnl' ),
38 'description' => 'List funnels with optional search, type and status filters. Returns id, name, status, type, step count and step types per funnel. Use this to resolve a funnel name to an ID before any write.',
39 'input_schema' => [
40 'type' => 'object',
41 'properties' => array_merge(
42 [
43 'search' => [
44 'type' => 'string',
45 'description' => 'Match against the funnel name.',
46 ],
47 'type' => [
48 'type' => 'string',
49 'description' => 'Filter by funnel type.',
50 'enum' => [ 'wc', 'lms', 'lead', 'store_checkout' ],
51 ],
52 'status' => [
53 'type' => 'string',
54 'description' => 'Filter by post status.',
55 'enum' => [ 'publish', 'draft', 'trash', 'any' ],
56 'default' => 'any',
57 ],
58 ],
59 MCPHelper::paginationSchema()
60 ),
61 ],
62 'execute_callback' => [ __CLASS__, 'listFunnels' ],
63 'permission_callback' => MCPHelper::currentUserCan(),
64 'annotations' => [ 'readonly' ],
65 ],
66 'wpfunnels/get-funnel' => [
67 'label' => __( 'Get Funnel', 'wpfnl' ),
68 'description' => 'Full detail for one funnel: metadata, ordered steps with their types and URLs, and the canvas URL. Does not return page-builder content — use get-step-outline for that.',
69 'input_schema' => [
70 'type' => 'object',
71 'properties' => [
72 'funnel_id' => [
73 'type' => 'integer',
74 'description' => 'Funnel post ID.',
75 ],
76 ],
77 'required' => [ 'funnel_id' ],
78 ],
79 'execute_callback' => [ __CLASS__, 'getFunnel' ],
80 'permission_callback' => MCPHelper::currentUserCan(),
81 'annotations' => [ 'readonly' ],
82 ],
83 'wpfunnels/create-funnel' => [
84 'label' => __( 'Create Funnel', 'wpfnl' ),
85 'description' => 'Create an EMPTY funnel with blank steps and no design — a fallback, not the default. Before calling this, call wpfunnels/list-funnel-templates: a matching template imports the funnel AND a real page-builder design for every step in one call (wpfunnels/import-funnel-template), which this tool cannot do. Only use create-funnel when no template fits or the user explicitly asked for a from-scratch/blank build. Steps are added separately with create-step. Type defaults to wc when WooCommerce is active, otherwise lead.',
86 'input_schema' => [
87 'type' => 'object',
88 'properties' => [
89 'name' => [
90 'type' => 'string',
91 'description' => 'Funnel name shown in the dashboard.',
92 ],
93 'type' => [
94 'type' => 'string',
95 'description' => 'Funnel type. wc for WooCommerce sales, lead for opt-in, lms for course funnels.',
96 'enum' => self::TYPES,
97 ],
98 'status' => [
99 'type' => 'string',
100 'description' => 'Initial post status.',
101 'enum' => [ 'publish', 'draft' ],
102 'default' => 'draft',
103 ],
104 ],
105 'required' => [ 'name' ],
106 ],
107 'execute_callback' => [ __CLASS__, 'createFunnel' ],
108 'permission_callback' => MCPHelper::currentUserCan(),
109 'annotations' => [],
110 ],
111 'wpfunnels/update-funnel' => [
112 'label' => __( 'Update Funnel', 'wpfnl' ),
113 'description' => 'Rename a funnel, change its type, publish/unpublish it, or update its tracking and offer settings (UTM defaults, Facebook Pixel, GTM, skip-offer). Publishing makes the funnel live for visitors, and changing type can strip settings that do not apply to the new type — both need confirmation.',
114 'input_schema' => [
115 'type' => 'object',
116 'properties' => [
117 'funnel_id' => [
118 'type' => 'integer',
119 'description' => 'Funnel post ID.',
120 ],
121 'name' => [
122 'type' => 'string',
123 'description' => 'New funnel name.',
124 ],
125 'type' => [
126 'type' => 'string',
127 'description' => 'New funnel type.',
128 'enum' => self::TYPES,
129 ],
130 'status' => [
131 'type' => 'string',
132 'description' => 'New post status. publish makes the funnel live.',
133 'enum' => [ 'publish', 'draft' ],
134 ],
135 'utm' => [
136 'type' => 'object',
137 'description' => 'UTM parameter defaults appended to this funnel\'s links. Any field left out keeps its current saved value.',
138 'properties' => [
139 'enabled' => [
140 'type' => 'boolean',
141 'description' => 'Whether UTM parameters are appended to funnel links.',
142 ],
143 'source' => [
144 'type' => 'string',
145 'description' => 'Default utm_source value.',
146 ],
147 'medium' => [
148 'type' => 'string',
149 'description' => 'Default utm_medium value.',
150 ],
151 'campaign' => [
152 'type' => 'string',
153 'description' => 'Default utm_campaign value.',
154 ],
155 'content' => [
156 'type' => 'string',
157 'description' => 'Default utm_content value.',
158 ],
159 ],
160 ],
161 'fb_pixel_disabled' => [
162 'type' => 'boolean',
163 'description' => 'Disable Facebook Pixel tracking for this funnel.',
164 ],
165 'gtm_disabled' => [
166 'type' => 'boolean',
167 'description' => 'Disable Google Tag Manager tracking for this funnel.',
168 ],
169 'skip_offer' => [
170 'type' => 'object',
171 'description' => 'Conditions under which the order-bump/upsell offer is skipped for this funnel. Any field left out keeps its current saved value.',
172 'properties' => [
173 'enabled' => [
174 'type' => 'boolean',
175 'description' => 'Skip the offer entirely for this funnel.',
176 ],
177 'skip_if_quantity' => [
178 'type' => 'boolean',
179 'description' => 'Skip the offer when the triggering product quantity condition is met.',
180 ],
181 ],
182 ],
183 'skip_recurring_offer' => [
184 'type' => 'boolean',
185 'description' => 'Skip showing the offer again for recurring/subscription orders. Note: this can only be turned on through this tool, not off — the underlying setting has no explicit "off" write path.',
186 ],
187 'skip_recurring_offer_within_days' => [
188 'type' => 'integer',
189 'description' => 'Number of days within which a recurring order skips the offer. A value of 0 is ignored (no-op) by the underlying setting.',
190 ],
191 ],
192 'required' => [ 'funnel_id' ],
193 ],
194 'execute_callback' => [ __CLASS__, 'updateFunnel' ],
195 'permission_callback' => MCPHelper::currentUserCan(),
196 'annotations' => [ 'destructive' ],
197 ],
198 'wpfunnels/trash-funnel' => [
199 'label' => __( 'Trash Funnel', 'wpfnl' ),
200 'description' => 'Move a funnel to the trash. It stops accepting visitors immediately but can be restored with restore-funnel until it is permanently deleted.',
201 'input_schema' => [
202 'type' => 'object',
203 'properties' => [
204 'funnel_id' => [ 'type' => 'integer', 'description' => 'Funnel post ID.' ],
205 ],
206 'required' => [ 'funnel_id' ],
207 ],
208 'execute_callback' => [ __CLASS__, 'trashFunnel' ],
209 'permission_callback' => MCPHelper::currentUserCan(),
210 'annotations' => [ 'destructive' ],
211 ],
212 'wpfunnels/restore-funnel' => [
213 'label' => __( 'Restore Funnel', 'wpfnl' ),
214 'description' => 'Restore a trashed funnel back to its previous status.',
215 'input_schema' => [
216 'type' => 'object',
217 'properties' => [
218 'funnel_id' => [ 'type' => 'integer', 'description' => 'Funnel post ID.' ],
219 ],
220 'required' => [ 'funnel_id' ],
221 ],
222 'execute_callback' => [ __CLASS__, 'restoreFunnel' ],
223 'permission_callback' => MCPHelper::currentUserCan(),
224 'annotations' => [],
225 ],
226 'wpfunnels/delete-funnel' => [
227 'label' => __( 'Permanently Delete Funnel', 'wpfnl' ),
228 'description' => 'Permanently delete a funnel and all of its steps. This cannot be undone — prefer trash-funnel unless the site owner explicitly asked for permanent deletion.',
229 'input_schema' => [
230 'type' => 'object',
231 'properties' => [
232 'funnel_id' => [ 'type' => 'integer', 'description' => 'Funnel post ID.' ],
233 ],
234 'required' => [ 'funnel_id' ],
235 ],
236 'execute_callback' => [ __CLASS__, 'deleteFunnel' ],
237 'permission_callback' => MCPHelper::currentUserCan(),
238 'annotations' => [ 'destructive' ],
239 ],
240 'wpfunnels/bulk-funnel-action' => [
241 'label' => __( 'Bulk Funnel Action', 'wpfnl' ),
242 'description' => 'Trash, restore or permanently delete several funnels in one call. Each funnel gets the same per-tool confirmation guarantee as the single-funnel version.',
243 'input_schema' => [
244 'type' => 'object',
245 'properties' => [
246 'funnel_ids' => [
247 'type' => 'array',
248 'description' => 'Funnel post IDs.',
249 'items' => [ 'type' => 'integer' ],
250 ],
251 'action' => [
252 'type' => 'string',
253 'enum' => [ 'trash', 'restore', 'delete' ],
254 ],
255 'dry_run' => [
256 'type' => 'boolean',
257 'description' => 'When true, validates the funnel IDs and reports what would happen without changing anything.',
258 'default' => false,
259 ],
260 ],
261 'required' => [ 'funnel_ids', 'action' ],
262 ],
263 'execute_callback' => [ __CLASS__, 'bulkFunnelAction' ],
264 'permission_callback' => MCPHelper::currentUserCan(),
265 'annotations' => [ 'destructive' ],
266 ],
267 'wpfunnels/export-funnel' => [
268 'label' => __( 'Export Funnel', 'wpfnl' ),
269 'description' => 'Export one or more funnels to the same portable JSON format the Import/Export screen produces, for backup or moving to another site.',
270 'input_schema' => [
271 'type' => 'object',
272 'properties' => [
273 'funnel_ids' => [
274 'type' => 'array',
275 'description' => 'Funnel post IDs to export.',
276 'items' => [ 'type' => 'integer' ],
277 ],
278 ],
279 'required' => [ 'funnel_ids' ],
280 ],
281 'execute_callback' => [ __CLASS__, 'exportFunnel' ],
282 'permission_callback' => MCPHelper::currentUserCan(),
283 'annotations' => [ 'readonly' ],
284 ],
285 'wpfunnels/get-funnel-preview-links' => [
286 'label' => __( 'Get Funnel Preview Links', 'wpfnl' ),
287 'description' => 'Front-end preview URLs for every step of a funnel, plus the canvas (builder) URL — use this to hand the site owner a link after building or changing a funnel.',
288 'input_schema' => [
289 'type' => 'object',
290 'properties' => [
291 'funnel_id' => [ 'type' => 'integer', 'description' => 'Funnel post ID.' ],
292 ],
293 'required' => [ 'funnel_id' ],
294 ],
295 'execute_callback' => [ __CLASS__, 'getFunnelPreviewLinks' ],
296 'permission_callback' => MCPHelper::currentUserCan(),
297 'annotations' => [ 'readonly' ],
298 ],
299 'wpfunnels/duplicate-funnel' => [
300 'label' => __( 'Duplicate Funnel', 'wpfnl' ),
301 'description' => 'Duplicate a funnel: clones the funnel post, all of its steps, page-builder content and settings into a new funnel named "<original> - Copy". Use this instead of manually recreating a funnel a user wants to reuse as a starting point.',
302 'input_schema' => [
303 'type' => 'object',
304 'properties' => [
305 'funnel_id' => [
306 'type' => 'integer',
307 'description' => 'Funnel post ID to duplicate.',
308 ],
309 ],
310 'required' => [ 'funnel_id' ],
311 ],
312 'execute_callback' => [ __CLASS__, 'duplicateFunnel' ],
313 'permission_callback' => MCPHelper::currentUserCan(),
314 'annotations' => [],
315 ],
316 'wpfunnels/import-funnel' => [
317 'label' => __( 'Import Funnel', 'wpfnl' ),
318 'description' => 'Import funnel(s) from exported JSON (the "data" array from wpfunnels/export-funnel, or the parsed contents of a file exported by the Import/Export screen). Always creates NEW funnels — never overwrites an existing one. Subject to the free-plan funnel-count limit.',
319 'input_schema' => [
320 'type' => 'object',
321 'properties' => [
322 'funnels' => [
323 'type' => 'array',
324 'description' => 'Exported funnel data objects, exactly as produced by wpfunnels/export-funnel\'s "data" field.',
325 'items' => [ 'type' => 'object' ],
326 ],
327 ],
328 'required' => [ 'funnels' ],
329 ],
330 'execute_callback' => [ __CLASS__, 'importFunnel' ],
331 'permission_callback' => MCPHelper::currentUserCan(),
332 'annotations' => [ 'destructive' ],
333 ],
334 ];
335 }
336
337 /**
338 * List funnels.
339 *
340 * @param array $input Tool input.
341 * @return array
342 */
343 public static function listFunnels( $input = [] ) {
344 $per_page = MCPHelper::perPage( isset( $input['per_page'] ) ? $input['per_page'] : 0 );
345 $page = max( 1, isset( $input['page'] ) ? (int) $input['page'] : 1 );
346 $status = isset( $input['status'] ) && 'any' !== $input['status'] ? $input['status'] : [ 'publish', 'draft' ];
347
348 $args = [
349 'post_type' => WPFNL_FUNNELS_POST_TYPE,
350 'post_status' => $status,
351 'posts_per_page' => $per_page,
352 'paged' => $page,
353 'orderby' => 'modified',
354 'order' => 'DESC',
355 ];
356
357 if ( ! empty( $input['search'] ) ) {
358 $args['s'] = sanitize_text_field( $input['search'] );
359 }
360
361 if ( ! empty( $input['type'] ) ) {
362 $args['meta_query'] = [
363 [
364 'key' => '_wpfnl_funnel_type',
365 'value' => sanitize_text_field( $input['type'] ),
366 ],
367 ];
368 }
369
370 $query = new \WP_Query( $args );
371 $items = array_map(
372 static function ( $post ) {
373 return MCPHelper::formatFunnel( $post );
374 },
375 $query->posts
376 );
377
378 return MCPHelper::paginate( $items, $query->found_posts, $page, $per_page );
379 }
380
381 /**
382 * Get one funnel with its steps.
383 *
384 * @param array $input Tool input.
385 * @return array|\WP_Error
386 */
387 public static function getFunnel( $input = [] ) {
388 $funnel = MCPHelper::requireFunnel( isset( $input['funnel_id'] ) ? $input['funnel_id'] : 0 );
389 if ( is_wp_error( $funnel ) ) {
390 return $funnel;
391 }
392
393 return MCPHelper::formatFunnel( $funnel, true );
394 }
395
396 /**
397 * Create a funnel.
398 *
399 * @param array $input Tool input.
400 * @return array|\WP_Error
401 */
402 public static function createFunnel( $input = [] ) {
403 $name = isset( $input['name'] ) ? sanitize_text_field( $input['name'] ) : '';
404 if ( '' === $name ) {
405 return MCPHelper::error( 'missing_name', 'A funnel name is required.' );
406 }
407
408 $store = Wpfnl::get_instance()->funnel_store;
409 $funnel_id = $store->create( $name );
410
411 if ( is_wp_error( $funnel_id ) || ! $funnel_id ) {
412 return MCPHelper::error( 'create_failed', 'WordPress could not create the funnel post.' );
413 }
414
415 // Resolve the type: explicit input wins, otherwise follow what this site
416 // can actually run. create() already guesses from global settings, so only
417 // overwrite when we have something better.
418 $type = isset( $input['type'] ) ? sanitize_text_field( $input['type'] ) : '';
419 if ( '' === $type ) {
420 $type = get_post_meta( $funnel_id, '_wpfnl_funnel_type', true );
421 }
422 if ( '' === $type || ! in_array( $type, self::TYPES, true ) ) {
423 $type = Wpfnl_functions::is_wc_active() ? 'wc' : 'lead';
424 }
425 update_post_meta( $funnel_id, '_wpfnl_funnel_type', $type );
426
427 $status = isset( $input['status'] ) && 'publish' === $input['status'] ? 'publish' : 'draft';
428 wp_update_post(
429 [
430 'ID' => $funnel_id,
431 'post_status' => $status,
432 ]
433 );
434
435 // Initialise the steps order so later create-step calls append cleanly.
436 $store->set_id( $funnel_id );
437 $store->set_steps_order();
438
439 ContextTools::invalidateCache();
440
441 return [
442 'success' => true,
443 'funnel_id' => (int) $funnel_id,
444 'name' => $name,
445 'type' => $type,
446 'status' => $status,
447 'canvas_url' => MCPHelper::canvasUrl( $funnel_id ),
448 'next_step' => 'Add steps with wpfunnels/create-step, starting with a landing step.',
449 ];
450 }
451
452 /**
453 * Duplicate a funnel and all of its steps/settings.
454 *
455 * Reuses the same clone routine the dashboard's "Duplicate" action calls
456 * (Wpfnl_Funnel_Store_Data::clone_funnel(), via admin/modules/funnel/
457 * class-wpfnl-funnel.php:clone_funnel()), driven the same way createFunnel()
458 * above drives the funnel store directly.
459 *
460 * @param array $input Tool input.
461 * @return array|\WP_Error
462 */
463 public static function duplicateFunnel( $input = [] ) {
464 $funnel = MCPHelper::requireFunnel( isset( $input['funnel_id'] ) ? $input['funnel_id'] : 0 );
465 if ( is_wp_error( $funnel ) ) {
466 return $funnel;
467 }
468
469 $store = Wpfnl::get_instance()->funnel_store;
470 $store->read( (int) $funnel->ID );
471 $new_funnel_id = $store->clone_funnel();
472
473 if ( ! $new_funnel_id || is_wp_error( $new_funnel_id ) ) {
474 return MCPHelper::error(
475 'duplicate_failed',
476 is_wp_error( $new_funnel_id ) ? $new_funnel_id->get_error_message() : 'WordPress could not duplicate this funnel.'
477 );
478 }
479
480 ContextTools::invalidateCache();
481
482 return [
483 'success' => true,
484 'funnel_id' => (int) $new_funnel_id,
485 'name' => get_the_title( $new_funnel_id ),
486 'type' => get_post_meta( $new_funnel_id, '_wpfnl_funnel_type', true ),
487 'status' => get_post_status( $new_funnel_id ),
488 'canvas_url' => MCPHelper::canvasUrl( $new_funnel_id ),
489 'next_step' => 'The duplicate is a separate funnel — review its steps with wpfunnels/get-funnel before publishing it.',
490 ];
491 }
492
493 /**
494 * Update a funnel.
495 *
496 * @param array $input Tool input.
497 * @return array|\WP_Error
498 */
499 public static function updateFunnel( $input = [] ) {
500 $funnel = MCPHelper::requireFunnel( isset( $input['funnel_id'] ) ? $input['funnel_id'] : 0 );
501 if ( is_wp_error( $funnel ) ) {
502 return $funnel;
503 }
504
505 $funnel_id = (int) $funnel->ID;
506 $changed = [];
507 $post_args = [ 'ID' => $funnel_id ];
508
509 if ( isset( $input['name'] ) && '' !== trim( (string) $input['name'] ) ) {
510 $post_args['post_title'] = sanitize_text_field( $input['name'] );
511 $changed[] = 'name';
512 }
513
514 if ( isset( $input['status'] ) && in_array( $input['status'], [ 'publish', 'draft' ], true ) ) {
515 $post_args['post_status'] = $input['status'];
516 $changed[] = 'status';
517 }
518
519 if ( count( $post_args ) > 1 ) {
520 $updated = wp_update_post( $post_args, true );
521 if ( is_wp_error( $updated ) ) {
522 return $updated;
523 }
524 if ( isset( $post_args['post_status'] ) && 'publish' === $post_args['post_status'] ) {
525 $user_id = function_exists( 'get_current_user_id' ) ? get_current_user_id() : 0;
526 do_action( 'wpfunnels_ai_funnel_published', $funnel_id, $user_id );
527 }
528 }
529
530 if ( isset( $input['type'] ) && in_array( $input['type'], self::TYPES, true ) ) {
531 $current = get_post_meta( $funnel_id, '_wpfnl_funnel_type', true );
532 if ( 'store_checkout' === $current ) {
533 return MCPHelper::error(
534 'store_checkout_type_locked',
535 'This funnel is a store checkout. Converting it to another type is not supported through this tool.'
536 );
537 }
538 update_post_meta( $funnel_id, '_wpfnl_funnel_type', sanitize_text_field( $input['type'] ) );
539 $changed[] = 'type';
540 }
541
542 // Tracking/offer settings all funnel through the same real routine the
543 // funnel settings screen calls (Wpfnl_Funnel::wpfnl_update_funnel_settings()),
544 // so the exact postmeta shapes it expects/writes stay in one place.
545 $settings_payload = [ 'funnel_id' => $funnel_id ];
546
547 if ( isset( $input['utm'] ) && is_array( $input['utm'] ) ) {
548 // wpfnl_update_funnel_settings() overwrites _wpfunnels_utm_params
549 // wholesale, so any field the caller left out must be backfilled
550 // from the currently saved value rather than dropped.
551 $existing_utm = get_post_meta( $funnel_id, '_wpfunnels_utm_params', true );
552 $existing_utm = is_array( $existing_utm ) ? $existing_utm : [];
553
554 $settings_payload['utm_settings'] = [
555 'utm_enable' => array_key_exists( 'enabled', $input['utm'] )
556 ? (bool) $input['utm']['enabled']
557 : ( isset( $existing_utm['utm_enable'] ) && 'on' === $existing_utm['utm_enable'] ),
558 'utm_source' => isset( $input['utm']['source'] ) ? sanitize_text_field( $input['utm']['source'] ) : ( isset( $existing_utm['utm_source'] ) ? $existing_utm['utm_source'] : '' ),
559 'utm_medium' => isset( $input['utm']['medium'] ) ? sanitize_text_field( $input['utm']['medium'] ) : ( isset( $existing_utm['utm_medium'] ) ? $existing_utm['utm_medium'] : '' ),
560 'utm_campaign' => isset( $input['utm']['campaign'] ) ? sanitize_text_field( $input['utm']['campaign'] ) : ( isset( $existing_utm['utm_campaign'] ) ? $existing_utm['utm_campaign'] : '' ),
561 'utm_content' => isset( $input['utm']['content'] ) ? sanitize_text_field( $input['utm']['content'] ) : ( isset( $existing_utm['utm_content'] ) ? $existing_utm['utm_content'] : '' ),
562 ];
563 $changed[] = 'utm';
564 }
565
566 if ( isset( $input['fb_pixel_disabled'] ) ) {
567 $settings_payload['is_fb_pixel'] = (bool) $input['fb_pixel_disabled'];
568 $changed[] = 'fb_pixel_disabled';
569 }
570
571 if ( isset( $input['gtm_disabled'] ) ) {
572 $settings_payload['is_gtm'] = (bool) $input['gtm_disabled'];
573 $changed[] = 'gtm_disabled';
574 }
575
576 if ( isset( $input['skip_offer'] ) && is_array( $input['skip_offer'] )
577 && ( array_key_exists( 'enabled', $input['skip_offer'] ) || array_key_exists( 'skip_if_quantity', $input['skip_offer'] ) )
578 ) {
579 // wpfnl_update_funnel_settings() only writes _wpfunnels_skip_offer
580 // when BOTH keys are present, so backfill whichever one was omitted.
581 $existing_skip = get_post_meta( $funnel_id, '_wpfunnels_skip_offer', true );
582 $existing_skip = is_array( $existing_skip ) ? $existing_skip : [];
583
584 $settings_payload['skip_offer'] = array_key_exists( 'enabled', $input['skip_offer'] )
585 ? (bool) $input['skip_offer']['enabled']
586 : ( isset( $existing_skip['skip_offer'] ) && 'yes' === $existing_skip['skip_offer'] );
587
588 $settings_payload['skip_if_quantity'] = array_key_exists( 'skip_if_quantity', $input['skip_offer'] )
589 ? (bool) $input['skip_offer']['skip_if_quantity']
590 : ( isset( $existing_skip['skip_if_quantity'] ) && 'yes' === $existing_skip['skip_if_quantity'] );
591
592 $changed[] = 'skip_offer';
593 }
594
595 if ( isset( $input['skip_recurring_offer_within_days'] ) ) {
596 $settings_payload['skip_recurring_offer_within_days'] = (int) $input['skip_recurring_offer_within_days'];
597 // The underlying routine only writes this meta when non-empty, so a
598 // value of 0 is silently ignored — that limitation is inherited here.
599 if ( ! empty( $settings_payload['skip_recurring_offer_within_days'] ) ) {
600 $changed[] = 'skip_recurring_offer_within_days';
601 }
602 }
603
604 if ( isset( $input['skip_recurring_offer'] ) ) {
605 $settings_payload['skip_recurring_offer'] = (bool) $input['skip_recurring_offer'];
606 // The underlying routine only writes this meta when truthy, so it
607 // has no explicit "off" write path — that limitation is inherited here.
608 if ( $settings_payload['skip_recurring_offer'] ) {
609 $changed[] = 'skip_recurring_offer';
610 }
611 }
612
613 if ( count( $settings_payload ) > 1 ) {
614 if ( ! class_exists( '\WPFunnels\Modules\Admin\Funnel\Module' ) ) {
615 return MCPHelper::error( 'settings_module_unavailable', 'The funnel settings routine is not available.' );
616 }
617
618 $settings_result = FunnelAdminModule::instance()->wpfnl_update_funnel_settings( $settings_payload );
619 if ( empty( $settings_result['success'] ) ) {
620 return MCPHelper::error( 'settings_update_failed', 'WordPress could not save the funnel settings.' );
621 }
622 }
623
624 if ( empty( $changed ) ) {
625 return MCPHelper::error( 'nothing_to_update', 'Provide at least one of name, type, status, utm, fb_pixel_disabled, gtm_disabled, skip_offer, skip_recurring_offer or skip_recurring_offer_within_days.' );
626 }
627
628 ContextTools::invalidateCache();
629
630 return [
631 'success' => true,
632 'funnel' => MCPHelper::formatFunnel( get_post( $funnel_id ) ),
633 'changed' => $changed,
634 ];
635 }
636
637 /**
638 * Trash a funnel.
639 *
640 * @param array $input Tool input.
641 * @return array|\WP_Error
642 */
643 public static function trashFunnel( $input = [] ) {
644 $funnel = MCPHelper::requireFunnel( isset( $input['funnel_id'] ) ? $input['funnel_id'] : 0 );
645 if ( is_wp_error( $funnel ) ) {
646 return $funnel;
647 }
648
649 $result = wp_trash_post( $funnel->ID );
650 if ( ! $result ) {
651 return MCPHelper::error( 'trash_failed', 'WordPress could not trash this funnel.' );
652 }
653
654 ContextTools::invalidateCache();
655
656 return [
657 'success' => true,
658 'funnel_id' => (int) $funnel->ID,
659 'status' => 'trash',
660 ];
661 }
662
663 /**
664 * Restore a trashed funnel.
665 *
666 * @param array $input Tool input.
667 * @return array|\WP_Error
668 */
669 public static function restoreFunnel( $input = [] ) {
670 $funnel_id = isset( $input['funnel_id'] ) ? (int) $input['funnel_id'] : 0;
671 $post = $funnel_id ? get_post( $funnel_id ) : null;
672
673 if ( ! $post || WPFNL_FUNNELS_POST_TYPE !== $post->post_type ) {
674 return MCPHelper::error( 'funnel_not_found', sprintf( 'No funnel found with ID %d.', $funnel_id ) );
675 }
676
677 $result = wp_untrash_post( $funnel_id );
678 if ( ! $result ) {
679 return MCPHelper::error( 'restore_failed', 'WordPress could not restore this funnel.' );
680 }
681
682 ContextTools::invalidateCache();
683
684 return [
685 'success' => true,
686 'funnel' => MCPHelper::formatFunnel( get_post( $funnel_id ) ),
687 ];
688 }
689
690 /**
691 * Permanently delete a funnel.
692 *
693 * @param array $input Tool input.
694 * @return array|\WP_Error
695 */
696 public static function deleteFunnel( $input = [] ) {
697 $funnel = MCPHelper::requireFunnel( isset( $input['funnel_id'] ) ? $input['funnel_id'] : 0 );
698 if ( is_wp_error( $funnel ) ) {
699 return $funnel;
700 }
701
702 $funnel_id = (int) $funnel->ID;
703 $result = wp_delete_post( $funnel_id, true );
704
705 if ( ! $result ) {
706 return MCPHelper::error( 'delete_failed', 'WordPress could not delete this funnel.' );
707 }
708
709 ContextTools::invalidateCache();
710
711 return [
712 'success' => true,
713 'funnel_id' => $funnel_id,
714 ];
715 }
716
717 /**
718 * Trash, restore or permanently delete several funnels.
719 *
720 * @param array $input Tool input.
721 * @return array|\WP_Error
722 */
723 public static function bulkFunnelAction( $input = [] ) {
724 $funnel_ids = isset( $input['funnel_ids'] ) && is_array( $input['funnel_ids'] ) ? array_map( 'intval', $input['funnel_ids'] ) : [];
725 $action = isset( $input['action'] ) ? (string) $input['action'] : '';
726 $dry_run = ! empty( $input['dry_run'] );
727
728 if ( empty( $funnel_ids ) || ! in_array( $action, [ 'trash', 'restore', 'delete' ], true ) ) {
729 return MCPHelper::error( 'invalid_input', 'Provide funnel_ids and one of trash, restore, delete.' );
730 }
731
732 $results = [];
733 foreach ( $funnel_ids as $funnel_id ) {
734 $post = get_post( $funnel_id );
735 if ( ! $post || WPFNL_FUNNELS_POST_TYPE !== $post->post_type ) {
736 $results[] = [ 'funnel_id' => $funnel_id, 'success' => false, 'message' => 'Not a funnel.' ];
737 continue;
738 }
739
740 if ( $dry_run ) {
741 $results[] = [ 'funnel_id' => $funnel_id, 'success' => true, 'would' => $action, 'name' => $post->post_title ];
742 continue;
743 }
744
745 switch ( $action ) {
746 case 'trash':
747 $ok = (bool) wp_trash_post( $funnel_id );
748 break;
749 case 'restore':
750 $ok = (bool) wp_untrash_post( $funnel_id );
751 break;
752 default:
753 $ok = (bool) wp_delete_post( $funnel_id, true );
754 break;
755 }
756
757 $results[] = [ 'funnel_id' => $funnel_id, 'success' => $ok ];
758 }
759
760 if ( ! $dry_run ) {
761 ContextTools::invalidateCache();
762 }
763
764 return [
765 'success' => true,
766 'dry_run' => $dry_run,
767 'action' => $action,
768 'results' => $results,
769 ];
770 }
771
772 /**
773 * Export one or more funnels, reusing the same exporter the Import/Export
774 * admin screen calls.
775 *
776 * @param array $input Tool input.
777 * @return array|\WP_Error
778 */
779 public static function exportFunnel( $input = [] ) {
780 $funnel_ids = isset( $input['funnel_ids'] ) && is_array( $input['funnel_ids'] ) ? array_map( 'intval', $input['funnel_ids'] ) : [];
781 if ( empty( $funnel_ids ) ) {
782 return MCPHelper::error( 'missing_funnel_ids', 'Provide at least one funnel_id.' );
783 }
784
785 if ( ! class_exists( '\WPFunnels\Export\Wpfnl_Export' ) ) {
786 return MCPHelper::error( 'exporter_unavailable', 'The funnel exporter is not available.' );
787 }
788
789 return Wpfnl_Export::getInstance()->bulk_export_funnel(
790 [
791 'ids' => $funnel_ids,
792 'status' => 'all',
793 ]
794 );
795 }
796
797 /**
798 * Import funnels from previously-exported JSON, reusing the same importer
799 * the Import/Export admin screen calls (WPFunnels\Import\Wpfnl_Import).
800 *
801 * @param array $input Tool input.
802 * @return array|\WP_Error
803 */
804 public static function importFunnel( $input = [] ) {
805 $funnels = isset( $input['funnels'] ) && is_array( $input['funnels'] ) ? $input['funnels'] : [];
806 if ( empty( $funnels ) ) {
807 return MCPHelper::error( 'missing_funnels', 'Provide the funnels array from an export-funnel response.' );
808 }
809
810 if ( ! class_exists( '\WPFunnels\Import\Wpfnl_Import' ) || empty( Wpfnl::$instance->import_funnel ) ) {
811 return MCPHelper::error( 'importer_unavailable', 'The funnel importer is not available.' );
812 }
813
814 $before_ids = get_posts(
815 [
816 'post_type' => WPFNL_FUNNELS_POST_TYPE,
817 'posts_per_page' => -1,
818 'fields' => 'ids',
819 'post_status' => 'any',
820 ]
821 );
822
823 $upload_dir = wp_upload_dir();
824 $path = trailingslashit( $upload_dir['basedir'] ) . 'wpfunnels/import/';
825 if ( ! file_exists( $path ) ) {
826 wp_mkdir_p( $path );
827 }
828
829 $file = $path . wp_unique_filename( $path, 'mcp-import-' . uniqid() . '.json' );
830 if ( false === file_put_contents( $file, wp_json_encode( $funnels ) ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
831 return MCPHelper::error( 'write_failed', 'Could not write the import file.' );
832 }
833
834 // Runs the same synchronous logic the admin screen schedules via
835 // Action Scheduler; import_funnels() reads $file itself and deletes it.
836 Wpfnl::$instance->import_funnel->import_funnels( $file );
837
838 $after_ids = get_posts(
839 [
840 'post_type' => WPFNL_FUNNELS_POST_TYPE,
841 'posts_per_page' => -1,
842 'fields' => 'ids',
843 'post_status' => 'any',
844 ]
845 );
846
847 $created_ids = array_values( array_diff( $after_ids, $before_ids ) );
848
849 return [
850 'imported' => count( $created_ids ),
851 'created_funnel_ids' => $created_ids,
852 'message' => get_option( 'wpfnl_import_notice', '' ),
853 ];
854 }
855
856 /**
857 * Front-end and canvas links for a funnel and each of its steps.
858 *
859 * @param array $input Tool input.
860 * @return array|\WP_Error
861 */
862 public static function getFunnelPreviewLinks( $input = [] ) {
863 $funnel = MCPHelper::requireFunnel( isset( $input['funnel_id'] ) ? $input['funnel_id'] : 0 );
864 if ( is_wp_error( $funnel ) ) {
865 return $funnel;
866 }
867
868 $funnel_id = (int) $funnel->ID;
869 $steps = Wpfnl_functions::get_steps( $funnel_id );
870 $steps = is_array( $steps ) ? $steps : [];
871
872 return [
873 'funnel_id' => $funnel_id,
874 'canvas_url' => MCPHelper::canvasUrl( $funnel_id ),
875 'steps' => array_map(
876 static function ( $step ) {
877 return MCPHelper::formatStepSummary( $step );
878 },
879 $steps
880 ),
881 ];
882 }
883 }
884