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

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

590 lines 21.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * TemplateTools — MCP abilities for the WPFunnels Template Library.
4 *
5 * @package WPFunnels\MCP\Tools
6 * @since 3.13.0
7 */
8
9 namespace WPFunnels\MCP\Tools;
10
11 defined( 'ABSPATH' ) || exit;
12
13 use WPFunnels\MCP\Helpers\MCPHelper;
14 use WPFunnels\Rest\Controllers\TemplateLibraryController;
15 use WPFunnels\TemplateLibrary\Manager as TemplateLibraryManager;
16 use WPFunnels\Wpfnl_functions;
17
18 /**
19 * Class TemplateTools
20 */
21 class TemplateTools {
22
23 /**
24 * Funnel types the remote template API understands.
25 */
26 private const TYPES = [ 'wc', 'lead', 'lms', 'store_checkout' ];
27
28 /**
29 * Register tool definitions.
30 *
31 * @return array
32 */
33 public static function definitions() {
34 return [
35 'wpfunnels/list-funnel-templates' => [
36 'description' => 'List real pre-built funnel templates from the WPFunnels template library — each one includes a ready-made page-builder design for every step, not just a step list. Filterable by funnel type, industry/category, page builder, and free-text search. Call this before building a funnel from scratch: prefer importing a relevant template and then configuring it over hand-assembling steps.',
37 'category' => 'template',
38 'readonly' => true,
39 'destructive' => false,
40 'parameters' => [
41 'type' => 'object',
42 'properties' => [
43 'type' => [
44 'type' => 'string',
45 'enum' => self::TYPES,
46 'description' => 'Funnel type to filter by. Defaults to wc.',
47 ],
48 'category' => [
49 'type' => 'string',
50 'description' => 'Industry/category slug or name to filter by (e.g. "fitness", "ebook", "saas"). Match this against the site\'s products or niche when possible.',
51 ],
52 'builder' => [
53 'type' => 'string',
54 'description' => 'Filter by builder: gutenberg, elementor, divi, bricks, oxygen. Defaults to the site\'s active builder.',
55 ],
56 'search' => [
57 'type' => 'string',
58 'description' => 'Free-text search term matched against the template name and industry (case-insensitive substring match, applied on top of any type/category/builder filters).',
59 ],
60 ],
61 ],
62 'callback' => [ __CLASS__, 'listTemplates' ],
63 ],
64
65 'wpfunnels/get-funnel-template' => [
66 'description' => 'Get full detail for one funnel template by its remote ID — its steps (id, type, title) and industry — before deciding whether to import it.',
67 'category' => 'template',
68 'readonly' => true,
69 'destructive' => false,
70 'parameters' => [
71 'type' => 'object',
72 'properties' => [
73 'template_id' => [
74 'type' => 'integer',
75 'description' => 'Remote template ID from list-funnel-templates.',
76 ],
77 'builder' => [
78 'type' => 'string',
79 'description' => 'Builder the template was fetched for. Defaults to the site\'s active builder.',
80 ],
81 ],
82 'required' => [ 'template_id' ],
83 ],
84 'callback' => [ __CLASS__, 'getTemplate' ],
85 ],
86
87 'wpfunnels/import-funnel-template' => [
88 'description' => 'Import a real funnel template — creates the funnel and every step with its actual page-builder design cloned from the template, not a blank shell. Use this instead of create-funnel + create-step whenever a matching template exists. After import, continue configuring it (assign-products-to-step, upsert-order-bump, set-offer-routing) exactly as you would a hand-built funnel, then propose publishing.',
89 'category' => 'template',
90 'readonly' => false,
91 'parameters' => [
92 'type' => 'object',
93 'properties' => [
94 'template_id' => [
95 'type' => 'integer',
96 'description' => 'Remote template ID from list-funnel-templates or get-funnel-template.',
97 ],
98 'name' => [
99 'type' => 'string',
100 'description' => 'Name for the imported funnel. Defaults to the template title.',
101 ],
102 'type' => [
103 'type' => 'string',
104 'enum' => self::TYPES,
105 'description' => 'Funnel type to import. If omitted, the tool searches every type for a matching template ID.',
106 ],
107 'status' => [
108 'type' => 'string',
109 'enum' => [ 'publish', 'draft' ],
110 'default' => 'draft',
111 ],
112 'builder' => [
113 'type' => 'string',
114 'description' => 'Builder the template was fetched for. Defaults to the site\'s active builder.',
115 ],
116 ],
117 'required' => [ 'template_id' ],
118 ],
119 'callback' => [ __CLASS__, 'importTemplate' ],
120 ],
121
122 'wpfunnels/import-step-template' => [
123 'description' => 'Import ONE step\'s real page-builder design from the template library onto an existing funnel — creates a single new step post cloned from the template step, not a whole funnel. Use this instead of import-funnel-template when you only want to add one more page (e.g. an extra upsell or a bonus landing page) from the library to a funnel you already built or already imported. The new step is appended to the funnel\'s step list; open the canvas afterward to position it and wire up routing (set-offer-routing).',
124 'category' => 'template',
125 'readonly' => false,
126 'parameters' => [
127 'type' => 'object',
128 'properties' => [
129 'funnel_id' => [
130 'type' => 'integer',
131 'description' => 'Existing funnel to import the step into. Use wpfunnels/list-funnels to find it.',
132 ],
133 'template_id' => [
134 'type' => 'integer',
135 'description' => 'Remote template ID that contains the step, from list-funnel-templates or get-funnel-template.',
136 ],
137 'step_id' => [
138 'type' => 'integer',
139 'description' => 'Remote step ID within that template, from get-funnel-template\'s steps list.',
140 ],
141 'type' => [
142 'type' => 'string',
143 'enum' => self::TYPES,
144 'description' => 'Funnel type the template belongs to. If omitted, the tool searches every type for a matching template ID.',
145 ],
146 'builder' => [
147 'type' => 'string',
148 'description' => 'Builder the template was fetched for. Defaults to the site\'s active builder.',
149 ],
150 ],
151 'required' => [ 'funnel_id', 'template_id', 'step_id' ],
152 ],
153 'callback' => [ __CLASS__, 'importStepTemplate' ],
154 ],
155
156 // wpfunnels/apply-template-to-step — TODO(ai-v2): not implemented.
157 // Wpfnl_Source_Remote::import_step() can clone one remote step onto
158 // an *existing* funnel, but wiring that safely needs a way to pick
159 // which live step it replaces (and what happens to routing/meta
160 // that pointed at the old step) — a follow-up beyond this pass.
161 ];
162 }
163
164 /**
165 * Resolve which builder to fetch templates for.
166 *
167 * @param array $input Tool input.
168 * @return string
169 */
170 private static function builderType( $input ) {
171 return isset( $input['builder'] ) && '' !== $input['builder']
172 ? sanitize_text_field( $input['builder'] )
173 : Wpfnl_functions::get_builder_type();
174 }
175
176 /**
177 * Fetch the cached/remote template set for one funnel type.
178 *
179 * @param string $type Funnel type.
180 * @param string $builder Builder slug.
181 * @return array
182 */
183 private static function fetchTemplates( $type, $builder ) {
184 $controller = new TemplateLibraryController();
185 $data = $controller->get_funnels_data( $type, false, [], false, $builder );
186 $data = $controller->prepare_custom_step( $data );
187
188 return isset( $data['templates'] ) && is_array( $data['templates'] ) ? $data['templates'] : [];
189 }
190
191 /**
192 * Extract a template's remote ID regardless of casing.
193 *
194 * @param array $template Raw template record.
195 * @return int
196 */
197 private static function templateId( $template ) {
198 if ( isset( $template['ID'] ) ) {
199 return (int) $template['ID'];
200 }
201 return isset( $template['id'] ) ? (int) $template['id'] : 0;
202 }
203
204 /**
205 * Whether a template is pro-only.
206 *
207 * @param array $template Raw template record.
208 * @return bool
209 */
210 private static function isProTemplate( $template ) {
211 if ( ! empty( $template['is_pro'] ) ) {
212 return true;
213 }
214 return isset( $template['templateType'] ) && 'pro' === $template['templateType'];
215 }
216
217 /**
218 * A template's industry taxonomy term.
219 *
220 * The remote API puts this under `wpf_funnel_industry` as a full term
221 * object ({name, slug, ...}), not `industry`.
222 *
223 * @param array $template Raw template record.
224 * @return array{name: string, slug: string}
225 */
226 private static function industryOf( $template ) {
227 $industry = isset( $template['wpf_funnel_industry'] ) && is_array( $template['wpf_funnel_industry'] )
228 ? $template['wpf_funnel_industry']
229 : [];
230
231 return [
232 'name' => isset( $industry['name'] ) ? (string) $industry['name'] : '',
233 'slug' => isset( $industry['slug'] ) ? (string) $industry['slug'] : '',
234 ];
235 }
236
237 /**
238 * Summarize a template for list output.
239 *
240 * @param array $template Raw template record.
241 * @return array
242 */
243 private static function summarizeTemplate( $template ) {
244 $steps = isset( $template['steps'] ) && is_array( $template['steps'] ) ? $template['steps'] : [];
245
246 return [
247 'id' => self::templateId( $template ),
248 'name' => isset( $template['title'] ) ? $template['title'] : '',
249 'plan' => self::isProTemplate( $template ) ? 'pro' : 'free',
250 'industry' => self::industryOf( $template )['name'],
251 'steps_count' => count( $steps ),
252 'steps' => array_values(
253 array_map(
254 static function ( $step ) {
255 return isset( $step['step_type'] ) ? $step['step_type'] : '';
256 },
257 $steps
258 )
259 ),
260 'preview_url' => isset( $template['link'] ) ? $template['link'] : '',
261 ];
262 }
263
264 /**
265 * Find one template by ID, optionally restricted to a single type.
266 *
267 * @param int $template_id Remote template ID.
268 * @param string $builder Builder slug.
269 * @param string|null $type Funnel type to search, or null to search every type.
270 * @return array{template: array|null, type: string}
271 */
272 private static function findTemplate( $template_id, $builder, $type = null ) {
273 $types_to_try = $type ? [ $type ] : self::TYPES;
274
275 foreach ( $types_to_try as $candidate_type ) {
276 foreach ( self::fetchTemplates( $candidate_type, $builder ) as $template ) {
277 if ( self::templateId( $template ) === $template_id ) {
278 return [ 'template' => $template, 'type' => $candidate_type ];
279 }
280 }
281 }
282
283 return [ 'template' => null, 'type' => '' ];
284 }
285
286 /**
287 * List templates.
288 *
289 * @param array $input Tool input.
290 * @return array
291 */
292 public static function listTemplates( $input = [] ) {
293 $type = isset( $input['type'] ) ? sanitize_text_field( $input['type'] ) : 'wc';
294 $builder = self::builderType( $input );
295 $category = isset( $input['category'] ) ? sanitize_text_field( $input['category'] ) : '';
296 $search = isset( $input['search'] ) ? sanitize_text_field( $input['search'] ) : '';
297
298 $templates = self::fetchTemplates( $type, $builder );
299
300 if ( '' !== $category ) {
301 $wanted_slug = sanitize_title( $category );
302 $templates = array_values(
303 array_filter(
304 $templates,
305 static function ( $template ) use ( $category, $wanted_slug ) {
306 $industry = self::industryOf( $template );
307 return $wanted_slug === $industry['slug'] || strtolower( $category ) === strtolower( $industry['name'] );
308 }
309 )
310 );
311 }
312
313 // The underlying template API (TemplateLibraryController::get_funnels_data())
314 // has no free-text search parameter of its own — it only accepts
315 // type/step/builder — so `search` is applied client-side against the
316 // name and industry of whatever the type/builder/category filters above
317 // already returned.
318 if ( '' !== $search ) {
319 $templates = array_values(
320 array_filter(
321 $templates,
322 static function ( $template ) use ( $search ) {
323 $name = isset( $template['title'] ) ? (string) $template['title'] : '';
324 $industry = self::industryOf( $template )['name'];
325 return false !== stripos( $name, $search ) || false !== stripos( $industry, $search );
326 }
327 )
328 );
329 }
330
331 $summaries = array_map( [ __CLASS__, 'summarizeTemplate' ], $templates );
332
333 return [
334 'count' => count( $summaries ),
335 'templates' => $summaries,
336 ];
337 }
338
339 /**
340 * Get template detail.
341 *
342 * @param array $input Tool input.
343 * @return array|\WP_Error
344 */
345 public static function getTemplate( $input = [] ) {
346 $template_id = isset( $input['template_id'] ) ? (int) $input['template_id'] : 0;
347 if ( ! $template_id ) {
348 return MCPHelper::error( 'missing_template_id', 'template_id is required.' );
349 }
350
351 $builder = self::builderType( $input );
352 $found = self::findTemplate( $template_id, $builder );
353
354 if ( null === $found['template'] ) {
355 return MCPHelper::error( 'template_not_found', sprintf( 'No template found with ID %d.', $template_id ) );
356 }
357
358 $template = $found['template'];
359 $steps = isset( $template['steps'] ) && is_array( $template['steps'] ) ? $template['steps'] : [];
360
361 return [
362 'id' => self::templateId( $template ),
363 'name' => isset( $template['title'] ) ? $template['title'] : '',
364 'type' => $found['type'],
365 'plan' => self::isProTemplate( $template ) ? 'pro' : 'free',
366 'industry' => self::industryOf( $template )['name'],
367 'preview_url' => isset( $template['link'] ) ? $template['link'] : '',
368 'steps' => array_map(
369 static function ( $step ) {
370 return [
371 'id' => isset( $step['ID'] ) ? (int) $step['ID'] : ( isset( $step['id'] ) ? (int) $step['id'] : 0 ),
372 'step_type' => isset( $step['step_type'] ) ? $step['step_type'] : '',
373 'title' => isset( $step['title'] ) ? $step['title'] : '',
374 ];
375 },
376 $steps
377 ),
378 ];
379 }
380
381 /**
382 * Import a template: creates the funnel, then clones every step's real
383 * page-builder design onto it by driving the same
384 * Wpfnl_Source_Remote::import_funnel() / import_step() calls the
385 * Template Library UI uses.
386 *
387 * @param array $input Tool input.
388 * @return array|\WP_Error
389 */
390 public static function importTemplate( $input = [] ) {
391 $template_id = isset( $input['template_id'] ) ? (int) $input['template_id'] : 0;
392 if ( ! $template_id ) {
393 return MCPHelper::error( 'missing_template_id', 'template_id is required.' );
394 }
395
396 $builder = self::builderType( $input );
397 $explicit_type = isset( $input['type'] ) && in_array( $input['type'], self::TYPES, true ) ? $input['type'] : null;
398 $found = self::findTemplate( $template_id, $builder, $explicit_type );
399
400 if ( null === $found['template'] ) {
401 return MCPHelper::error( 'template_not_found', sprintf( 'No template found with ID %d.', $template_id ) );
402 }
403
404 $template = $found['template'];
405 $resolved_type = $found['type'];
406 $steps = isset( $template['steps'] ) && is_array( $template['steps'] ) ? $template['steps'] : [];
407
408 if ( empty( $steps ) ) {
409 return MCPHelper::error( 'template_has_no_steps', 'This template has no steps to import.' );
410 }
411
412 $name = isset( $input['name'] ) && '' !== trim( (string) $input['name'] )
413 ? sanitize_text_field( $input['name'] )
414 : ( isset( $template['title'] ) && '' !== $template['title']
415 ? sanitize_text_field( $template['title'] )
416 : sprintf( 'Imported Template %d', $template_id ) );
417
418 $manager = new TemplateLibraryManager();
419 $source = $manager->get_source( 'remote' );
420 if ( ! $source ) {
421 return MCPHelper::error( 'template_source_unavailable', 'The remote template source is unavailable.' );
422 }
423
424 $status = isset( $input['status'] ) && 'publish' === $input['status'] ? 'publish' : 'draft';
425
426 $import_args = [
427 'source' => 'remote',
428 'remoteID' => $template_id,
429 'name' => $name,
430 'type' => 'store_checkout' === $resolved_type ? 'wc' : $resolved_type,
431 'status' => $status,
432 'steps' => $steps,
433 ];
434 if ( 'store_checkout' === $resolved_type ) {
435 $import_args['is_store_checkout'] = 'true';
436 }
437
438 $import_result = $source->import_funnel( $import_args );
439
440 if ( empty( $import_result['success'] ) || empty( $import_result['funnelID'] ) ) {
441 return MCPHelper::error(
442 'import_failed',
443 isset( $import_result['message'] ) ? $import_result['message'] : 'Could not import the template funnel.'
444 );
445 }
446
447 $funnel_id = (int) $import_result['funnelID'];
448 $imported_steps = [];
449
450 foreach ( $steps as $step ) {
451 $step_result = $source->import_step(
452 [
453 'funnelID' => $funnel_id,
454 'step' => $step,
455 'source' => 'remote',
456 'importType' => 'templates',
457 ]
458 );
459
460 if ( empty( $step_result['success'] ) || empty( $step_result['stepID'] ) ) {
461 continue;
462 }
463
464 $imported_steps[] = [
465 'step_id' => (int) $step_result['stepID'],
466 'step_type' => isset( $step['step_type'] ) ? $step['step_type'] : '',
467 ];
468 }
469
470 if ( empty( $imported_steps ) ) {
471 return MCPHelper::error( 'import_failed', 'The funnel was created but no steps could be imported.' );
472 }
473
474 ContextTools::invalidateCache();
475
476 return [
477 'success' => true,
478 'funnel_id' => $funnel_id,
479 'name' => $name,
480 'template_id' => $template_id,
481 'imported_steps' => $imported_steps,
482 'canvas_url' => MCPHelper::canvasUrl( $funnel_id ),
483 'next_step' => 'Design and step structure are imported. Continue with assign-products-to-step, upsert-order-bump, and set-offer-routing to finish configuring it, then propose publishing.',
484 ];
485 }
486
487 /**
488 * Import a single template step onto an existing funnel — the
489 * step-level counterpart to importTemplate(), driven by the same
490 * Wpfnl_Source_Remote::import_step() call the Template Library UI's
491 * "add step from library" flow uses.
492 *
493 * Unlike importTemplate()'s per-step loop — which runs against a
494 * funnel freshly cloned from the same template, so its canvas already
495 * carries placeholder nodes keyed by each remote step id — this
496 * targets a funnel that already exists and has no such placeholder.
497 * So this intentionally omits `importType => 'templates'` (which would
498 * try, and fail, to swap a placeholder step id in `_funnel_data`) and
499 * instead passes `isSingleStep => 'yes'`, which is what
500 * Wpfnl_Source_Remote::import_step() uses to append the new step to
501 * the funnel's `_steps_order` directly. The step is not auto-positioned
502 * on the visual canvas layout — see the apply-template-to-step TODO
503 * above for why that wiring (picking a position, rerouting) is out of
504 * scope here too.
505 *
506 * @param array $input Tool input.
507 * @return array|\WP_Error
508 */
509 public static function importStepTemplate( $input = [] ) {
510 $funnel = MCPHelper::requireFunnel( isset( $input['funnel_id'] ) ? $input['funnel_id'] : 0 );
511 if ( is_wp_error( $funnel ) ) {
512 return $funnel;
513 }
514
515 $template_id = isset( $input['template_id'] ) ? (int) $input['template_id'] : 0;
516 if ( ! $template_id ) {
517 return MCPHelper::error( 'missing_template_id', 'template_id is required.' );
518 }
519
520 $step_id = isset( $input['step_id'] ) ? (int) $input['step_id'] : 0;
521 if ( ! $step_id ) {
522 return MCPHelper::error( 'missing_step_id', 'step_id is required.' );
523 }
524
525 $builder = self::builderType( $input );
526 $explicit_type = isset( $input['type'] ) && in_array( $input['type'], self::TYPES, true ) ? $input['type'] : null;
527 $found = self::findTemplate( $template_id, $builder, $explicit_type );
528
529 if ( null === $found['template'] ) {
530 return MCPHelper::error( 'template_not_found', sprintf( 'No template found with ID %d.', $template_id ) );
531 }
532
533 $template_steps = isset( $found['template']['steps'] ) && is_array( $found['template']['steps'] ) ? $found['template']['steps'] : [];
534 $step = null;
535
536 foreach ( $template_steps as $candidate ) {
537 if ( self::templateId( $candidate ) === $step_id ) {
538 $step = $candidate;
539 break;
540 }
541 }
542
543 if ( null === $step ) {
544 return MCPHelper::error(
545 'template_step_not_found',
546 sprintf( 'No step with ID %d was found in template %d.', $step_id, $template_id )
547 );
548 }
549
550 $manager = new TemplateLibraryManager();
551 $source = $manager->get_source( 'remote' );
552 if ( ! $source ) {
553 return MCPHelper::error( 'template_source_unavailable', 'The remote template source is unavailable.' );
554 }
555
556 $step_result = $source->import_step(
557 [
558 'funnelID' => $funnel->ID,
559 'step' => $step,
560 'source' => 'remote',
561 'isSingleStep' => 'yes',
562 ]
563 );
564
565 if ( empty( $step_result['success'] ) || empty( $step_result['stepID'] ) ) {
566 return MCPHelper::error(
567 'import_failed',
568 isset( $step_result['message'] ) ? $step_result['message'] : 'Could not import the template step.'
569 );
570 }
571
572 ContextTools::invalidateCache();
573
574 return [
575 'success' => true,
576 'funnel_id' => (int) $funnel->ID,
577 'template_id' => $template_id,
578 'step' => [
579 'step_id' => (int) $step_result['stepID'],
580 'step_type' => isset( $step['step_type'] ) ? $step['step_type'] : '',
581 'title' => isset( $step['title'] ) ? $step['title'] : '',
582 'edit_url' => isset( $step_result['stepEditLink'] ) ? $step_result['stepEditLink'] : '',
583 'view_url' => isset( $step_result['stepViewLink'] ) ? $step_result['stepViewLink'] : '',
584 ],
585 'canvas_url' => MCPHelper::canvasUrl( $funnel->ID ),
586 'next_step' => 'The step has been created and appended to the funnel\'s step order. Open the canvas to position it and wire up routing (set-offer-routing), then continue configuring it (assign-products-to-step, upsert-order-bump) as needed.',
587 ];
588 }
589 }
590