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

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

380 lines 12.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * ProductTools — WooCommerce product lookup and step assignment.
4 *
5 * Product IDs are never invented: the model resolves them here first, and
6 * assignment re-validates every ID against WooCommerce before writing.
7 *
8 * @package WPFunnels\MCP
9 * @since 3.13.0
10 */
11
12 namespace WPFunnels\MCP\Tools;
13
14 defined( 'ABSPATH' ) || exit;
15
16 use WPFunnels\MCP\Helpers\MCPHelper;
17 use WPFunnels\Wpfnl_functions;
18
19 /**
20 * Class ProductTools
21 */
22 class ProductTools {
23
24 /**
25 * Ability definitions for this domain.
26 *
27 * @return array
28 */
29 public static function definitions() {
30 return [
31 'wpfunnels/search-products' => [
32 'label' => __( 'Search Products', 'wpfnl' ),
33 'description' => 'Search WooCommerce products by name or SKU and return id, name, type, price, stock and rating. Always resolve a product name to an ID here before assigning it to a step.',
34 'input_schema' => [
35 'type' => 'object',
36 'properties' => array_merge(
37 [
38 'search' => [
39 'type' => 'string',
40 'description' => 'Name or SKU fragment. Omit to list best sellers.',
41 ],
42 'include_variations' => [
43 'type' => 'boolean',
44 'description' => 'Expand variable products into their variations.',
45 'default' => false,
46 ],
47 ],
48 MCPHelper::paginationSchema()
49 ),
50 ],
51 'execute_callback' => [ __CLASS__, 'searchProducts' ],
52 'permission_callback' => MCPHelper::currentUserCan(),
53 'annotations' => [ 'readonly' ],
54 ],
55 'wpfunnels/assign-products-to-step' => [
56 'label' => __( 'Assign Products To Step', 'wpfnl' ),
57 'description' => 'Set the products on a checkout, upsell or downsell step, with an optional percentage discount. This REPLACES the step\'s existing products, so pass the complete list. Only these three step types can hold products.',
58 'input_schema' => [
59 'type' => 'object',
60 'properties' => [
61 'step_id' => [
62 'type' => 'integer',
63 'description' => 'Step post ID (checkout, upsell or downsell).',
64 ],
65 'products' => [
66 'type' => 'array',
67 'description' => 'Complete product list for this step.',
68 'items' => [
69 'type' => 'object',
70 'properties' => [
71 'product_id' => [
72 'type' => 'integer',
73 'description' => 'WooCommerce product or variation ID from search-products.',
74 ],
75 'quantity' => [
76 'type' => 'integer',
77 'description' => 'Quantity.',
78 'minimum' => 1,
79 'default' => 1,
80 ],
81 ],
82 'required' => [ 'product_id' ],
83 ],
84 ],
85 'discount_percentage' => [
86 'type' => 'number',
87 'description' => 'Percentage off the regular price for this step. 0 means full price. Typical starting points: checkout 0, order bump 15, upsell 15, downsell 20.',
88 'minimum' => 0,
89 'maximum' => 100,
90 ],
91 ],
92 'required' => [ 'step_id', 'products' ],
93 ],
94 'execute_callback' => [ __CLASS__, 'assignProductsToStep' ],
95 'permission_callback' => MCPHelper::currentUserCan(),
96 'annotations' => [ 'destructive' ],
97 ],
98 'wpfunnels/get-product' => [
99 'label' => __( 'Get Product', 'wpfnl' ),
100 'description' => 'Full detail for one WooCommerce product by ID: name, type, price, stock and rating. Use search-products first if you only have a name.',
101 'input_schema' => [
102 'type' => 'object',
103 'properties' => [
104 'product_id' => [
105 'type' => 'integer',
106 'description' => 'WooCommerce product or variation ID.',
107 ],
108 ],
109 'required' => [ 'product_id' ],
110 ],
111 'execute_callback' => [ __CLASS__, 'getProduct' ],
112 'permission_callback' => MCPHelper::currentUserCan(),
113 'annotations' => [ 'readonly' ],
114 ],
115 ];
116 }
117
118 /**
119 * Get a single product.
120 *
121 * @param array $input Tool input.
122 * @return array|\WP_Error
123 */
124 public static function getProduct( $input = [] ) {
125 if ( ! Wpfnl_functions::is_wc_active() || ! function_exists( 'wc_get_product' ) ) {
126 return MCPHelper::error( 'woocommerce_inactive', 'WooCommerce is not active on this site.' );
127 }
128
129 $product_id = isset( $input['product_id'] ) ? (int) $input['product_id'] : 0;
130 $product = $product_id ? wc_get_product( $product_id ) : null;
131
132 if ( ! $product ) {
133 return MCPHelper::error(
134 'product_not_found',
135 sprintf( 'No product found with ID %d. Use wpfunnels/search-products to find one.', $product_id )
136 );
137 }
138
139 return self::formatProduct( $product );
140 }
141
142 /**
143 * Search products.
144 *
145 * @param array $input Tool input.
146 * @return array|\WP_Error
147 */
148 public static function searchProducts( $input = [] ) {
149 if ( ! Wpfnl_functions::is_wc_active() || ! function_exists( 'wc_get_products' ) ) {
150 return MCPHelper::error(
151 'woocommerce_inactive',
152 'WooCommerce is not active on this site, so there are no products to search.'
153 );
154 }
155
156 $per_page = MCPHelper::perPage( isset( $input['per_page'] ) ? $input['per_page'] : 0 );
157 $page = max( 1, isset( $input['page'] ) ? (int) $input['page'] : 1 );
158 $search = isset( $input['search'] ) ? trim( (string) $input['search'] ) : '';
159
160 $args = [
161 'status' => 'publish',
162 'limit' => $per_page,
163 'page' => $page,
164 'paginate' => true,
165 'orderby' => '' !== $search ? 'relevance' : 'popularity',
166 'order' => 'DESC',
167 ];
168
169 if ( '' !== $search ) {
170 $args['s'] = $search;
171 }
172
173 $results = wc_get_products( $args );
174 $items = [];
175
176 foreach ( $results->products as $product ) {
177 $items[] = self::formatProduct( $product );
178
179 if ( ! empty( $input['include_variations'] ) && $product->is_type( 'variable' ) ) {
180 foreach ( $product->get_children() as $variation_id ) {
181 $variation = wc_get_product( $variation_id );
182 if ( $variation ) {
183 $items[] = self::formatProduct( $variation, $product->get_name() );
184 }
185 }
186 }
187 }
188
189 return MCPHelper::paginate( $items, (int) $results->total, $page, $per_page );
190 }
191
192 /**
193 * Replace the products attached to a step.
194 *
195 * @param array $input Tool input.
196 * @return array|\WP_Error
197 */
198 public static function assignProductsToStep( $input = [] ) {
199 if ( ! Wpfnl_functions::is_wc_active() || ! function_exists( 'wc_get_product' ) ) {
200 return MCPHelper::error( 'woocommerce_inactive', 'WooCommerce is not active, so products cannot be attached.' );
201 }
202
203 $step = MCPHelper::requireStep( isset( $input['step_id'] ) ? $input['step_id'] : 0 );
204 if ( is_wp_error( $step ) ) {
205 return $step;
206 }
207
208 $step_id = (int) $step->ID;
209 $step_type = (string) get_post_meta( $step_id, '_step_type', true );
210
211 if ( ! MCPHelper::stepTypeHoldsProducts( $step_type ) ) {
212 return MCPHelper::error(
213 'step_type_holds_no_products',
214 sprintf(
215 'A %s step cannot hold products. Only checkout, upsell and downsell steps can.',
216 $step_type ? $step_type : 'unknown'
217 ),
218 [ 'step_type' => $step_type ]
219 );
220 }
221
222 $requested = isset( $input['products'] ) && is_array( $input['products'] ) ? $input['products'] : [];
223 if ( empty( $requested ) ) {
224 return MCPHelper::error( 'missing_products', 'Pass at least one product. To clear a step, remove it instead.' );
225 }
226
227 $resolved = [];
228 $rejected = [];
229
230 foreach ( $requested as $entry ) {
231 $product_id = isset( $entry['product_id'] ) ? (int) $entry['product_id'] : 0;
232 $product = $product_id ? wc_get_product( $product_id ) : null;
233
234 if ( ! $product ) {
235 $rejected[] = [
236 'product_id' => $product_id,
237 'reason' => 'No WooCommerce product exists with this ID.',
238 ];
239 continue;
240 }
241
242 if ( ! $product->is_purchasable() ) {
243 $rejected[] = [
244 'product_id' => $product_id,
245 'name' => $product->get_name(),
246 'reason' => 'Product is not purchasable (missing price or unsupported type).',
247 ];
248 continue;
249 }
250
251 $resolved[] = [
252 'id' => $product_id,
253 'quantity' => isset( $entry['quantity'] ) ? max( 1, (int) $entry['quantity'] ) : 1,
254 ];
255 }
256
257 if ( empty( $resolved ) ) {
258 return MCPHelper::error(
259 'no_valid_products',
260 'None of the given products could be used. Resolve IDs with wpfunnels/search-products first.',
261 [ 'rejected' => $rejected ]
262 );
263 }
264
265 update_post_meta( $step_id, MCPHelper::productMetaKey( $step_type ), $resolved );
266
267 $discount = isset( $input['discount_percentage'] ) ? (float) $input['discount_percentage'] : null;
268 if ( null !== $discount && $discount > 0 ) {
269 self::saveDiscount( $step_id, $step_type, $discount );
270 }
271
272 ContextTools::invalidateCache();
273
274 return [
275 'success' => true,
276 'step_id' => $step_id,
277 'step_type' => $step_type,
278 'products' => self::attachedProducts( $step_id, $step_type ),
279 'discount_percentage' => null !== $discount ? $discount : 0,
280 'rejected' => $rejected,
281 'replaced' => true,
282 ];
283 }
284
285 /**
286 * Products currently attached to a step, hydrated from WooCommerce.
287 *
288 * @param int $step_id Step id.
289 * @param string $step_type Step type.
290 * @return array
291 */
292 public static function attachedProducts( $step_id, $step_type ) {
293 $stored = get_post_meta( $step_id, MCPHelper::productMetaKey( $step_type ), true );
294 if ( ! is_array( $stored ) || empty( $stored ) ) {
295 return [];
296 }
297
298 $items = [];
299 foreach ( $stored as $entry ) {
300 $product_id = isset( $entry['id'] ) ? (int) $entry['id'] : 0;
301 $product = $product_id && function_exists( 'wc_get_product' ) ? wc_get_product( $product_id ) : null;
302
303 $items[] = [
304 'product_id' => $product_id,
305 'quantity' => isset( $entry['quantity'] ) ? (int) $entry['quantity'] : 1,
306 'name' => $product ? $product->get_name() : '(product no longer exists)',
307 'price' => $product ? (float) $product->get_price() : null,
308 'exists' => (bool) $product,
309 ];
310 }
311
312 return $items;
313 }
314
315 /**
316 * Persist the step-level discount in the meta shape each step type expects.
317 *
318 * WPFunnels stores one discount per step, in a different key and a different
319 * array shape per type — hence the switch rather than one generic write.
320 *
321 * @param int $step_id Step id.
322 * @param string $step_type Step type.
323 * @param float $discount Percentage off.
324 * @return void
325 */
326 private static function saveDiscount( $step_id, $step_type, $discount ) {
327 $discount = min( 100, max( 0, $discount ) );
328
329 if ( 'checkout' === $step_type ) {
330 update_post_meta(
331 $step_id,
332 '_wpfnl_checkout_discount_main_product',
333 [
334 'discountOptions' => 'discount-percentage',
335 'discountapplyto' => 'regular',
336 'mutedDiscountValue' => $discount,
337 ]
338 );
339 return;
340 }
341
342 update_post_meta(
343 $step_id,
344 'upsell' === $step_type ? '_wpfnl_upsell_discount' : '_wpfnl_downsell_discount',
345 [
346 'discountType' => 'discount-percentage',
347 'discountApplyTo' => 'regular',
348 'discountValue' => (string) $discount,
349 ]
350 );
351 }
352
353 /**
354 * Format a product (or variation) for tool output.
355 *
356 * @param \WC_Product $product Product object.
357 * @param string $parent_name Parent name when formatting a variation.
358 * @return array
359 */
360 private static function formatProduct( $product, $parent_name = '' ) {
361 return [
362 'product_id' => $product->get_id(),
363 'name' => '' !== $parent_name
364 ? $parent_name . '' . wp_strip_all_tags( $product->get_formatted_name() )
365 : $product->get_name(),
366 'type' => $product->get_type(),
367 'sku' => $product->get_sku(),
368 'price' => '' !== $product->get_price() ? (float) $product->get_price() : null,
369 'regular_price' => '' !== $product->get_regular_price() ? (float) $product->get_regular_price() : null,
370 'on_sale' => $product->is_on_sale(),
371 'stock_status' => $product->get_stock_status(),
372 'total_sales' => (int) $product->get_total_sales(),
373 'average_rating' => (float) $product->get_average_rating(),
374 'purchasable' => $product->is_purchasable(),
375 'virtual' => $product->is_virtual(),
376 'downloadable' => $product->is_downloadable(),
377 ];
378 }
379 }
380