PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.25
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.25
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
fluent-cart / app / Http / Controllers / ProductController.php

ProductController.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.3.25, at app/Http/Controllers/ProductController.php

1,532 lines 49.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Http\Controllers;
4
5 use FluentCart\Api\Resource\ProductDetailResource;
6 use FluentCart\Api\Resource\ProductResource;
7 use FluentCart\Api\Resource\ProductVariationResource;
8 use FluentCart\Api\Resource\ShopResource;
9 use FluentCart\Api\Taxonomy;
10 use FluentCart\App\CPT\FluentProducts;
11 use FluentCart\App\Helpers\AdminHelper;
12 use FluentCart\App\Helpers\Helper;
13 use FluentCart\App\Http\Requests\ProductCreateRequest;
14 use FluentCart\App\Http\Requests\ProductRequest;
15 use FluentCart\App\Http\Requests\ProductUpdateRequest;
16 use FluentCart\App\Http\Requests\UpgradePathSettingRequest;
17 use FluentCart\App\Models\Meta;
18 use FluentCart\App\Models\Product;
19 use FluentCart\App\Models\ProductDetail;
20 use FluentCart\App\Models\ProductDownload;
21 use FluentCart\App\Models\ProductMeta;
22 use FluentCart\App\Models\ProductVariation;
23 use FluentCart\App\Models\ShippingClass;
24 use FluentCart\App\Models\TaxClass;
25 use FluentCart\App\Modules\ReportingModule\ProductReport;
26 use FluentCart\App\Services\Async\DummyProductService;
27 use FluentCart\App\Services\BulkProductInsertService;
28 use FluentCart\App\Services\BulkProductUpdateService;
29 use FluentCart\App\Services\Filter\ProductFilter;
30 use FluentCart\App\Services\PlanUpgradeService;
31 use FluentCart\Framework\Database\Orm\Builder;
32 use FluentCart\Framework\Http\Request\Request;
33 use FluentCart\Framework\Support\Arr;
34 use FluentCart\Framework\Support\Collection;
35 use FluentCart\Framework\Support\Str;
36 use WP_REST_Response;
37 use FluentCart\Api\Helper as ApiHelper;
38
39 class ProductController extends Controller
40 {
41 public function index(Request $request): WP_REST_Response
42 {
43 //$request->set('with', ['detail', 'variants:post_id,available,manage_stock,stock_status,variation_title,other_info']);
44 $products = ProductFilter::fromRequest($request)->paginate();
45
46 $products->setCollection(
47 $products->getCollection()->transform(function ($product) {
48 return $product->setAppends(['view_url', 'edit_url']);
49 })
50 );
51
52 $products = apply_filters('fluent_cart/products_list', $products);
53
54 return $this->sendSuccess([
55 'products' => $products
56 ]);
57 }
58
59 public function find(Request $request, Product $product): array
60 {
61 if ($request->get('with')) {
62 $product->load($request->get('with'));
63 }
64 $data = [
65 'product' => $product,
66 ];
67
68 if (in_array('product_menu', $request->get('with', []))) {
69 $data['product_menu'] = AdminHelper::getProductMenu($product);
70 }
71
72 return $data;
73 }
74
75 public function getRelatedProducts(Request $request, $productId): WP_REST_Response
76 {
77 $productId = absint($productId);
78
79 if (!$productId) {
80 return $this->sendError('Invalid product ID');
81 }
82
83 $relatedBy = [];
84
85 if (filter_var($request->get('related_by_categories'), FILTER_VALIDATE_BOOLEAN)) {
86 $relatedBy[] = 'product-categories';
87 }
88
89 if (filter_var($request->get('related_by_brands'), FILTER_VALIDATE_BOOLEAN)) {
90 $relatedBy[] = 'product-brands';
91 }
92
93 $orderBy = sanitize_text_field($request->get('order_by', 'title_asc'));
94 $postsPerPage = absint($request->get('posts_per_page', 6));
95
96 $products = ShopResource::getSimilarProducts($productId, true, [
97 'related_by' => $relatedBy,
98 'order_by' => $orderBy,
99 'posts_per_page' => $postsPerPage,
100 ]);
101
102 return $this->sendSuccess([
103 'products' => $products
104 ]);
105 }
106
107
108 /**
109 *
110 * @param ProductRequest $request
111 * @return WP_REST_Response
112 */
113 public function create(ProductCreateRequest $request): WP_REST_Response
114 {
115
116 $data = $request->getSafe($request->sanitize());
117
118
119 $postData = array_filter(Arr::only($data, [
120 'post_title',
121 'post_status',
122 //'detail',
123 ]));
124
125 $postData['post_name'] = sanitize_title($postData['post_title']);
126
127 $postData['post_type'] = FluentProducts::CPT_NAME;
128 $createdPostId = wp_insert_post($postData);
129
130 if (is_wp_error($createdPostId)) {
131 return $this->sendError([
132 'code' => 403,
133 'message' => $createdPostId->get_error_message()
134 ]);
135 }
136
137 $detail = Arr::get($data, 'detail');
138 $detail['post_id'] = $createdPostId;
139
140 $isDigital = Arr::get($detail, 'fulfillment_type') === 'digital';
141
142 $createdProductDetail = ProductDetail::query()->create($detail);
143 $variation = ProductVariation::query()->create([
144 'post_id' => $createdPostId,
145 'serial_index' => 1,
146 'variation_title' => $postData['post_title'],
147 //'stock_status' => $isDigital ? 'in-stock' : 'out-of-stock',
148 'stock_status' => 'in-stock',
149 'payment_type' => 'onetime',
150 'total_stock' => 1,
151 'available' => 1,
152 'fulfillment_type' => $detail['fulfillment_type'],
153 'other_info' => [
154 'description' => '',
155 'payment_type' => 'onetime',
156 'times' => '',
157 'repeat_interval' => '',
158 'trial_days' => '',
159 'billing_summary' => '',
160 'manage_setup_fee' => 'no',
161 'signup_fee_name' => '',
162 'signup_fee' => '',
163 'setup_fee_per_item' => 'no',
164 'is_bundle_product' => Arr::get($detail, 'other_info.is_bundle_product', 'no'),
165 ]
166 ]);
167
168 if ($createdProductDetail) {
169 return $this->sendSuccess([
170 'data' => [
171 'ID' => $createdPostId,
172 'variant' => $variation,
173 'product_details' => Arr::get($createdProductDetail, 'data'),
174 ],
175 'message' => __('Product has been created successfully', 'fluent-cart')
176 ]);
177 }
178
179 return $this->sendError(['code' => 400, 'message' => __('Product creation failed!', 'fluent-cart')]);
180
181 }
182
183 /**
184 * Bulk insert products from import/manual entry.
185 *
186 * @param Request $request
187 * @return WP_REST_Response
188 */
189 public function bulkInsert(Request $request): WP_REST_Response
190 {
191 $products = $request->get('products', []);
192
193 if (empty($products) || !is_array($products)) {
194 return $this->sendError([
195 'message' => __('No products provided', 'fluent-cart'),
196 ]);
197 }
198
199 if (count($products) > 10) {
200 return $this->sendError([
201 'message' => __('Maximum 10 products per chunk allowed', 'fluent-cart'),
202 ]);
203 }
204
205 try {
206 $service = new BulkProductInsertService();
207 $result = $service->insertChunk($products);
208
209 if (empty($result['created']) && !empty($result['errors'])) {
210 return $this->sendError([
211 'message' => __('All products failed to insert', 'fluent-cart'),
212 'errors' => $result['errors'],
213 ]);
214 }
215
216 return $this->sendSuccess([
217 'message' => sprintf(
218 __('%d product(s) created successfully', 'fluent-cart'),
219 count($result['created'])
220 ),
221 'created' => $result['created'],
222 'errors' => $result['errors'],
223 ]);
224 } catch (\Throwable $e) {
225 return $this->sendError([
226 'message' => __('Bulk insert failed: ', 'fluent-cart') . $e->getMessage(),
227 ]);
228 }
229 }
230
231 /**
232 * Duplicate a product with selected options
233 *
234 * @param Request $request
235 * @param int $productId
236 * @return WP_REST_Response
237 */
238 public function duplicate(Request $request, $productId): WP_REST_Response
239 {
240 try {
241 $data = $request->getSafe([
242 'import_stock_management' => 'sanitize_text_field',
243 'import_license_settings' => 'sanitize_text_field',
244 'import_downloadable_files' => 'sanitize_text_field',
245 ]);
246
247 $importStockManagement = filter_var(
248 Arr::get($data, 'import_stock_management', false),
249 FILTER_VALIDATE_BOOLEAN
250 );
251 $importLicenseSettings = filter_var(
252 Arr::get($data, 'import_license_settings', false),
253 FILTER_VALIDATE_BOOLEAN
254 );
255 $importDownloadableFiles = filter_var(
256 Arr::get($data, 'import_downloadable_files', false),
257 FILTER_VALIDATE_BOOLEAN
258 );
259
260 try {
261 $newProductId = Product::duplicateProduct($productId, [
262 'import_stock_management' => $importStockManagement,
263 'import_license_settings' => $importLicenseSettings,
264 'import_downloadable_files' => $importDownloadableFiles,
265 ]);
266
267 return $this->sendSuccess([
268 'product_id' => $newProductId,
269 'message' => __('Product duplicated successfully. The new product has been saved as a draft.', 'fluent-cart')
270 ]);
271
272 } catch (\RuntimeException $e) {
273 if ((int)$e->getCode() === 404) {
274 return $this->sendError([
275 'message' => __('Product not found', 'fluent-cart')
276 ]);
277 }
278 return $this->sendError([
279 'message' => __('Failed to duplicate product: ', 'fluent-cart') . $e->getMessage()
280 ]);
281 } catch (\Exception $e) {
282 return $this->sendError([
283 'message' => __('Failed to duplicate product: ', 'fluent-cart') . $e->getMessage()
284 ]);
285 }
286
287 } catch (\Exception $e) {
288 return $this->sendError([
289 'message' => __('An error occurred while duplicating the product.', 'fluent-cart'),
290 'error' => $e->getMessage()
291 ]);
292 }
293 }
294
295 public function delete(Request $request, Product $product)
296 {
297
298 $isDeleted = ProductResource::delete($product->ID);
299
300 if (is_wp_error($isDeleted)) {
301 return $isDeleted;
302 }
303 return $this->response->sendSuccess($isDeleted);
304 }
305
306 public function update(ProductUpdateRequest $request, $postId)
307 {
308 $data = $request->getSafe($request->sanitize());
309
310 if (
311 Arr::get($data, 'detail.variation_type') === 'simple' &&
312 (empty(Arr::get($data, 'variants')) || empty(Arr::get($data, 'variants.0')))
313 ) {
314 return $this->sendError(
315 [
316 'message' => __('Variation info is not present', 'fluent-cart')
317 ]
318 );
319 }
320
321 // $hasError = ProductResource::validateDownloadableFiles($data);
322 // if (!empty($hasError)) {
323 // return $this->sendError($hasError);
324 // }
325
326 $isUpdated = ProductResource::update($data, $postId);
327
328
329 if (is_wp_error($isUpdated)) {
330 return $isUpdated;
331 }
332
333 do_action('fluent_cart/product_updated', [
334 'data' => $data,
335 'product' => $isUpdated['data']
336 ]);
337
338 return $this->response->sendSuccess($isUpdated);
339 }
340
341 public function updateLongDescEditorMode(Request $request, $postId)
342 {
343 // Validate input
344 $activeEditor = sanitize_text_field($request->get('active_editor'));
345
346 // Fetch product detail directly by post_id
347 $productDetail = ProductDetail::query()->where('post_id', $postId)->first();
348
349 if (!$productDetail) {
350 return $this->sendError([
351 'message' => __('Product not found', 'fluent-cart')
352 ]);
353 }
354
355 $otherInfo = $productDetail->other_info;
356 $otherInfo['active_editor'] = $activeEditor;
357
358 // Update product detail
359 $isUpdated = $productDetail->update([
360 'other_info' => $otherInfo
361 ]);
362
363 if (!$isUpdated) {
364 return $this->sendError([
365 'message' => __('Failed to update editor mode', 'fluent-cart')
366 ]);
367 }
368
369 return $this->sendSuccess([
370 'message' => __('Editor mode updated successfully', 'fluent-cart')
371 ]);
372 }
373
374
375 public function updateTaxClass(Request $request, $postId)
376 {
377 $taxClassId = sanitize_text_field(Arr::get($this->request->all(), 'tax_class', 0));
378
379 $taxClass = TaxClass::query()->findOrFail($taxClassId);
380
381 if (empty($taxClass)) {
382 return $this->sendError([
383 'message' => __('Tax Class not found', 'fluent-cart')
384 ]);
385 }
386
387 $productDetail = ProductDetail::query()->where('post_id', $postId)->first();
388
389 if (empty($productDetail)) {
390 return $this->sendError([
391 'message' => __('Product not found', 'fluent-cart')
392 ]);
393 }
394
395 // Get existing other_info and merge with new tax_class
396 $otherInfo = $productDetail->other_info;
397 $otherInfo['tax_class'] = $taxClass->id;
398
399 // update only tax_class inside the $productDetails->other_info
400 $productDetail->update([
401 'other_info' => $otherInfo
402 ]);
403
404 return $this->sendSuccess([
405 'message' => __('Tax Class updated successfully', 'fluent-cart')
406 ]);
407 }
408
409 public function removeTaxClass(Request $request, $postId)
410 {
411 $productDetail = ProductDetail::query()->where('post_id', $postId)->first();
412 if (empty($productDetail)) {
413 return $this->sendError([
414 'message' => __('Product not found', 'fluent-cart')
415 ]);
416 }
417 $otherInfo = $productDetail->other_info;
418 $otherInfo['tax_class'] = '';
419 $productDetail->update([
420 'other_info' => $otherInfo
421 ]);
422 return $this->sendSuccess([
423 'message' => __('Tax Class removed successfully', 'fluent-cart')
424 ]);
425 }
426
427 public function updateShippingClass(Request $request, $postId)
428 {
429 $shippingClassId = sanitize_text_field(Arr::get($this->request->all(), 'shipping_class', 0));
430 $shippingClass = ShippingClass::query()->findOrFail($shippingClassId);
431
432 if (empty($shippingClass)) {
433 return $this->sendError([
434 'message' => __('Shipping Class not found', 'fluent-cart')
435 ]);
436 }
437
438 $productDetail = ProductDetail::query()->where('post_id', $postId)->first();
439 if (empty($productDetail)) {
440 return $this->sendError([
441 'message' => __('Product not found', 'fluent-cart')
442 ]);
443 }
444 $otherInfo = $productDetail->other_info;
445 $otherInfo['shipping_class'] = $shippingClass->id;
446 $productDetail->update([
447 'other_info' => $otherInfo
448 ]);
449 return $this->sendSuccess([
450 'message' => __('Shipping Class updated successfully', 'fluent-cart')
451 ]);
452 }
453
454 public function removeShippingClass(Request $request, $postId)
455 {
456 $productDetail = ProductDetail::query()->where('post_id', $postId)->first();
457 if (empty($productDetail)) {
458 return $this->sendError([
459 'message' => __('Product not found', 'fluent-cart')
460 ]);
461 }
462
463 $otherInfo = $productDetail->other_info;
464 $otherInfo['shipping_class'] = '';
465 $productDetail->update([
466 'other_info' => $otherInfo
467 ]);
468
469 return $this->sendSuccess([
470 'message' => __('Shipping Class removed successfully', 'fluent-cart')
471 ]);
472 }
473
474 /**
475 *
476 * @param Request $request
477 * @param $productId
478 * @return WP_REST_Response
479 */
480 public function get(Request $request, $productId)
481 {
482 $product = Product::with([
483 'detail',
484 'variants' => function ($query) {
485 $query->with(['media'])
486 ->orderBy('serial_index', 'ASC');
487 }
488 ])->with('downloadable_files')->find($productId);
489
490 if (empty($product)) {
491 return $this->entityNotFoundError(
492 __('Product not found', 'fluent-cart'),
493 __('Back to Product List', 'fluent-cart'),
494 '/products'
495 );
496 }
497
498 // $product = ProductResource::search(['id' => $productId], function (Builder $query) {
499 // return $query
500 // ->with([
501 // 'detail',
502 // 'variants' => function ($query) {
503 // $query->with(['media'])
504 // ->orderBy('serial_index', 'ASC');
505 // }
506 // ])
507 // ->with('downloadable_files');
508 // }, true)->first();
509
510
511 if (!empty($product)) {
512 $product->setAppends(['view_url', 'edit_url']);
513 $taxonomies = Taxonomy::getTaxonomies();
514 $taxonomies = Collection::make($taxonomies)
515 ->map(function ($taxonomy) use (&$product) {
516 $taxonomy_object = get_taxonomy($taxonomy);
517 $all_labels = (array)get_taxonomy_labels($taxonomy_object);
518 $filtered_labels = Arr::only($all_labels, ['singular_name', 'name']);
519 $product[$taxonomy] = Taxonomy::getTermIdsFromTerms($product->getTermByType($taxonomy)->get()->toArray());
520
521 return [
522 'name' => $taxonomy,
523 'label' => Str::headline($taxonomy),
524 'terms' => Taxonomy::getFormattedTerms($taxonomy),
525 'labels' => $filtered_labels
526 ];
527 });
528
529 if (in_array('product_menu', $request->get('with', []))) {
530 $productMenu = AdminHelper::getProductMenu($product);
531 }
532
533 $featuredImageId = get_post_thumbnail_id($product->ID);
534 $productData = $product->toArray();
535 $productData['featured_image_id'] = $featuredImageId;
536 //get featured image id
537 return $this->sendSuccess([
538 'product' => $productData,
539 'product_menu' => $productMenu ?? "",
540 'taxonomies' => $taxonomies,
541 ]);
542 } else {
543 return $this->sendError([
544 'message' => __('Something went wrong', 'fluent-cart'),
545 ]);
546 }
547 //return ProductResource::find($postId);
548 }
549
550
551 public function getUpgradeSettings($id, Request $request): WP_REST_Response
552 {
553 return $this->sendSuccess(
554 [
555 'data' => PlanUpgradeService::getUpgradeSettings($id)
556 ]
557 );
558
559 }
560
561 public function saveUpgradeSetting(UpgradePathSettingRequest $request, $id): WP_REST_Response
562 {
563 $data = ApiHelper::sanitizeTextAll(
564 $request->except('query_timestamp')
565 );
566
567 $isSaved = PlanUpgradeService::saveUpgradeSetting($data);
568
569 if ($isSaved) {
570 return $this->sendSuccess(
571 [
572 'message' => __('Settings saved successfully', 'fluent-cart')
573 ]
574 );
575 } else {
576 return $this->sendError(
577 [
578 'message' => __('Failed to save settings', 'fluent-cart')
579 ]
580 );
581 }
582
583 }
584
585 public function deleteUpgradePath($id): WP_REST_Response
586 {
587 $isDeleted = Meta::query()->where('id', $id)->delete();
588 if ($isDeleted) {
589 return $this->sendSuccess(
590 [
591 'message' => __('Path deleted successfully', 'fluent-cart')
592 ]
593 );
594 } else {
595 return $this->sendError(
596 [
597 'message' => __('Failed to delete path', 'fluent-cart')
598 ]
599 );
600 }
601 }
602
603 public function updateUpgradePath(UpgradePathSettingRequest $request, $id): WP_REST_Response
604 {
605 $data = ApiHelper::sanitizeTextAll(
606 $request->except('query_timestamp')
607 );
608
609 $isUpdated = PlanUpgradeService::updateUpgradeSetting($id, $data);
610
611 if ($isUpdated) {
612 return $this->sendSuccess(
613 [
614 'message' => __('Settings updated successfully', 'fluent-cart')
615 ]
616 );
617 } else {
618 return $this->sendError(
619 [
620 'message' => __('Failed to update settings', 'fluent-cart')
621 ]
622 );
623 }
624
625 }
626
627 public function getUpgradePaths($variationId, Request $request)
628 {
629 $params = $request->get('params');
630 $orderHash = Arr::get($params, 'order_hash');
631
632 if (!$variationId || !$orderHash) {
633 return [];
634 }
635
636 $upgradePaths = PlanUpgradeService::getUpgardePathsFromVariation($variationId, $orderHash);
637
638 return $this->sendSuccess([
639 'upgradePaths' => $upgradePaths
640 ]);
641 }
642
643 public function getPricingWidgets(Request $request, $productId)
644 {
645 $thisMonthKey = '-' . gmdate('d') . ' days';
646 $ranges = [
647 'all_time' => __('All time', 'fluent-cart'),
648 '-30 days' => __('Last 30 days', 'fluent-cart'),
649 $thisMonthKey => __('This month', 'fluent-cart'),
650 ];
651
652 $stats = ProductReport::getStatByProductIds([$productId], array_keys($ranges));
653
654 $html = '<ul class="fct-lists">';
655
656 foreach ($ranges as $rangeSlug => $range) {
657 $stat = $stats[$rangeSlug];
658 $prefix = '';
659 if ($stat->total_quantity) {
660 $prefix = ' <b title="Quantity">(' . $stat->total_quantity . ')</b> ';
661 }
662 $html .= '<li> <span>' . $range . $prefix . '</span>' . '<span>' . Helper::toDecimal($stat->total_amount, true) . '</span>';
663 }
664
665 $html .= '</ul>';
666
667 $widgets = [
668 [
669 'title' => __('Quick Sales Overview', 'fluent-cart'),
670 'body' => $html,
671 ],
672 ];
673
674 return [
675 'widgets' => $widgets,
676 ];
677 }
678
679 public function updateProductDetail(Request $request, $id)
680 {
681 $data = $request->getSafe([
682 'variation_type' => 'sanitize_key',
683 'variation_ids.*' => 'intval',
684 'action' => 'sanitize_key'
685 ]);
686
687 $isUpdated = ProductDetailResource::update(
688 $data,
689 $id,
690 ['action' => Arr::get($data, 'action', 'change_variation_type')]
691 );
692
693 if (is_wp_error($isUpdated)) {
694 return $isUpdated;
695 }
696 return $this->response->sendSuccess($isUpdated);
697 }
698
699 public function syncTaxonomyTerms(Request $request, $id)
700 {
701
702 if (empty(($request->get('terms')))) {
703 $data['taxonomy'] = sanitize_key($request->get('taxonomy'));
704 } else {
705 $data = $request->getSafe([
706 'taxonomy' => 'sanitize_key',
707 'terms.*' => 'intval',
708 ]);
709 }
710 $isUpdated = ProductResource::syncTaxonomyTerms($data, $id);
711
712 if (is_wp_error($isUpdated)) {
713 return $isUpdated;
714 }
715 return $this->response->sendSuccess($isUpdated);
716 }
717
718 public function deleteTaxonomyTerms(Request $request, $id)
719 {
720
721 $data = $request->getSafe([
722 'taxonomy' => 'sanitize_key',
723 'term' => 'intval',
724 ]);
725 $isUpdated = ProductResource::deleteTaxonomyTerms($data, $id);
726
727 if (is_wp_error($isUpdated)) {
728 return $isUpdated;
729 }
730 return $this->response->sendSuccess($isUpdated);
731 }
732
733 public function updateVariantOption(Request $request, $postId)
734 {
735
736
737 $data = $request->all();
738 // ProductValidator::validate($data, [
739 // 'variation_type' => 'required',
740 // 'product_id' => 'required',
741 // 'options.*.id' => 'required',
742 // 'options.*.variants' => 'required',
743 // ]);
744 $isSynced = ProductResource::syncVariantOption($postId, $data);
745
746 if (is_wp_error($isSynced)) {
747 return $isSynced;
748 }
749 return $this->response->sendSuccess($isSynced);
750 }
751
752
753 public function addProductTerms(Request $request)
754 {
755 $data = $request->get('term');
756 $name = Arr::get($data, 'name', '');
757 $taxonomy = Arr::get($data, 'taxonomy', '');
758 $parent = Arr::get($data, 'parent', '');
759 $name = sanitize_text_field($name);
760 $taxonomy = sanitize_text_field($taxonomy);
761 $parent = sanitize_text_field($parent);
762
763 $args = [];
764
765 if (!empty($parent)) {
766 $args['parent'] = $parent;
767 }
768
769
770 $termNames = explode(',', $name);
771 $ids = Taxonomy::addTaxonomyTerms($taxonomy, $termNames, $args);
772
773 if (count($ids)) {
774 $this->response->json([
775 'term_ids' => $ids,
776 'names' => $termNames
777 ]);
778 } else {
779 $this->response->json([
780 'message' => __('Unable To Create Term/s', 'fluent-cart'),
781 ], 423);
782 }
783
784 }
785
786 public function getProductTermsList(): array
787 {
788 $taxonomies = Taxonomy::getTaxonomies();
789
790 $taxonomies = Collection::make($taxonomies)
791 ->map(function ($taxonomy) {
792 return [
793 'name' => $taxonomy,
794 'label' => Str::headline($taxonomy),
795 'terms' => Taxonomy::getFormattedTerms($taxonomy),
796 ];
797 });
798 return [
799 "taxonomies" => $taxonomies,
800 ];
801 }
802
803 public function getProductTermListByParent(Request $request): WP_REST_Response
804 {
805 $parentTerms = $request->get('parents');
806 $termsData = [];
807 foreach ($request->get('listeners') as $listener) {
808 $termsData[$listener] = Taxonomy::getFormattedTerms($listener, false, $parentTerms);
809 }
810 return $this->sendSuccess([
811 'data' => $termsData
812 ]);
813
814 }
815
816 public function handleBulkActions(Request $request)
817 {
818
819 $isUpdated = ProductResource::manageBulkActions($request->all());
820
821 if (is_wp_error($isUpdated)) {
822 return $isUpdated;
823 }
824 return $this->response->sendSuccess($isUpdated);
825 }
826
827 public static function getMimeGroups()
828 {
829 return apply_filters('fluent_support/mime_groups', [
830 'images' => [
831 'title' => __('Photos', 'fluent-cart'),
832 'mimes' => [
833 'image/gif',
834 'image/ief',
835 'image/jpeg',
836 'image/webp',
837 'image/pjpeg',
838 'image/ktx',
839 'image/png',
840 ],
841 ],
842 'csv' => [
843 'title' => __('CSV', 'fluent-cart'),
844 'mimes' => [
845 'application/csv',
846 'application/txt',
847 'text/csv',
848 'text/plain',
849 'text/comma-separated-values',
850 'text/anytext',
851 ],
852 ],
853 'documents' => [
854 'title' => __('PDF/Docs', 'fluent-cart'),
855 'mimes' => [
856 'application/excel',
857 'application/vnd.ms-excel',
858 'application/vnd.msexcel',
859 'application/octet-stream',
860 'application/pdf',
861 'application/msword',
862 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
863 ],
864 ],
865 'zip' => [
866 'title' => __('Zip', 'fluent-cart'),
867 'mimes' => [
868 'application/zip',
869 ],
870 ],
871 'json' => [
872 'title' => __('JSON', 'fluent-cart'),
873 'mimes' => [
874 'application/json',
875 'application/jsonml+json',
876 ],
877 ],
878 ]);
879 }
880
881 public function searchVariantByName(Request $request): array
882 {
883 $data = $request->getSafe([
884 'name' => 'sanitize_text_field',
885 'ids.*' => 'intval',
886 'search' => 'sanitize_text_field',
887 ]);
888
889 $name = Arr::get($data, 'name', '');
890 if (empty($name)) {
891 $name = Arr::get($data, 'search', '');
892 }
893 $ids = Arr::get($data, 'ids', []);
894 $productVariations = [];
895 $query = [];
896 if (!empty($name) || count($ids) > 0) {
897 $query = [
898 "ID" =>
899 [
900 "column" => "ID",
901 "operator" => "in",
902 "value" => Arr::get($data, 'ids', [])
903 ]
904 ,
905 "post_title" =>
906 [
907 "column" => "post_title",
908 "operator" => "like",
909 "value" => '%' . Arr::get($data, 'name') . '%'
910 ],
911 "post_status" =>
912 [
913 "column" => "post_status",
914 "operator" => "=",
915 "value" => 'publish'
916 ]
917 ];
918 }
919
920 $products = Product::query()
921 ->with('variants')
922 ->when(count($query), function (Builder $q) use ($query) {
923 return $q->search($query, function (Builder $query) {
924 return $query;
925 }, true);
926 })
927 ->when(empty($name), function (Builder $q) {
928 return $q->limit(10);
929 })
930 ->limit(20)->get()
931 ->map(function ($product) {
932 return [
933 'value' => $product->ID,
934 'label' => $product->post_title,
935 'children' => $product->variants->map(function ($variation) use ($product) {
936 return [
937 'value' => $variation->id,
938 // 'label' => $product->post_title . ' - ' . $variation->variation_title,
939 'label' => $variation->variation_title,
940 ];
941 })
942 ->toArray()
943 ];
944 })->toArray();
945 return $products;
946 }
947
948 public function searchProductVariantOptions(Request $request): array
949 {
950 $data = $request->getSafe([
951 'include_ids.*' => 'intval',
952 'search' => 'sanitize_text_field',
953 'scopes.*' => 'sanitize_text_field',
954 'subscription_status' => 'sanitize_text_field',
955 ]);
956
957 $subscription_status = Arr::get($data, 'subscription_status');
958 $search = Arr::get($data, 'search', '');
959 $includeIds = Arr::get($data, 'include_ids', []);
960
961 $productsQuery = Product::query()
962 ->where('post_status', 'publish');
963
964 $productsQuery->with(['detail', 'variants' => function ($query) use ($subscription_status) {
965 if ($subscription_status === 'not_subscribable') {
966 $query->where('payment_type', '!=', 'subscription');
967 }
968 }]);
969
970 $scopes = Arr::get($data, 'scopes', []);
971 if ($scopes) {
972 $productsQuery = $productsQuery->scopes($scopes);
973 }
974
975 if ($search) {
976 $productsQuery->where(function ($query) use ($search, $subscription_status) {
977 $query->where('post_title', 'like', '%' . $search . '%')
978 ->orWhereHas('variants', function ($query) use ($search, $subscription_status) {
979 $query->where('variation_title', 'like', "%$search%");
980 if ($subscription_status === 'not_subscribable') {
981 $query->where('payment_type', '!=', 'subscription');
982 }
983 });
984 });
985 }
986
987 $productsQuery->limit(20);
988
989 $products = $productsQuery->get();
990
991 $pushedVariationIds = [];
992 $formattedProducts = [];
993
994 foreach ($products as $product) {
995 $detail = $product->detail;
996 if ($detail && $detail->manage_stock && $detail->stock_availability !== Helper::IN_STOCK) {
997 continue;
998 }
999
1000 $formatted = [
1001 'value' => 'product_' . $product->ID,
1002 'label' => $product->post_title,
1003 ];
1004
1005 $variants = $product->variants;
1006
1007 $children = [];
1008 foreach ($variants as $variant) {
1009 if ($variant->manage_stock && $variant->stock_status !== Helper::IN_STOCK) {
1010 continue;
1011 }
1012 $pushedVariationIds[] = $variant->id;
1013 $children[] = [
1014 'value' => $variant->id,
1015 'label' => $variant->variation_title,
1016 ];
1017 }
1018
1019 if (!$children) {
1020 continue;
1021 }
1022
1023 $formatted['children'] = $children;
1024 $formattedProducts[$product->ID] = $formatted;
1025 }
1026
1027 $leftVariationIds = array_diff($includeIds, $pushedVariationIds);
1028
1029 if ($leftVariationIds) {
1030 $leftVariants = ProductVariation::query()
1031 ->whereIn('id', $leftVariationIds)
1032 ->with(['product' => function ($query) {
1033 $query->where('post_status', 'publish');
1034 }, 'product.detail'])
1035 ->get();
1036
1037 foreach ($leftVariants as $variant) {
1038 if ($subscription_status == 'not_subscribable' && $variant->payment_type === 'subscription') {
1039 continue;
1040 }
1041 if ($variant->manage_stock && $variant->stock_status !== Helper::IN_STOCK) {
1042 continue;
1043 }
1044 $product = $variant->product;
1045 if (!$product) {
1046 continue;
1047 }
1048 $detail = $product->detail;
1049 if ($detail && $detail->manage_stock && $detail->stock_availability !== Helper::IN_STOCK) {
1050 continue;
1051 }
1052 if (isset($formattedProducts[$product->ID])) {
1053 $formattedProducts[$product->ID]['children'][] = [
1054 'value' => $variant->id,
1055 'label' => $variant->variation_title,
1056 ];
1057 } else {
1058 $formattedProducts[$product->ID] = [
1059 'value' => 'product_' . $product->ID,
1060 'label' => $product->post_title,
1061 'children' => [
1062 [
1063 'value' => $variant->id,
1064 'label' => $variant->variation_title,
1065 ]
1066 ]
1067 ];
1068 }
1069 }
1070 }
1071
1072 $products = array_values($formattedProducts);
1073
1074 // sort the products by label
1075 usort($products, function ($a, $b) {
1076 return strcmp($a['label'], $b['label']);
1077 });
1078
1079 return [
1080 'products' => $products
1081 ];
1082 }
1083
1084 public function findSubscriptionVariants(Request $request)
1085 {
1086 $data = $request->getSafe([
1087 'name' => 'sanitize_text_field',
1088 ]);
1089
1090 $search = Arr::get($data, 'name', '');
1091
1092
1093 if (!empty($search)) {
1094 $query['variation_title'] = [
1095 'column' => 'variation_title',
1096 'operator' => 'like',
1097 'value' => '%' . $search . '%',
1098 ];
1099 }
1100
1101 $variants = ProductVariation::query()
1102 ->when(!empty($search), function (Builder $query) use ($search) {
1103 $query->where('variation_title', 'like', '%' . $search . '%');
1104 })
1105 ->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(other_info, '$.payment_type')) = ?", ['subscription'])
1106 ->get()
1107 ->map(function ($variation) {
1108 return [
1109 'id' => $variation->id,
1110 'title' => $variation->variation_title,
1111 ];
1112 })
1113 ->toArray();
1114
1115 return $variants;
1116
1117
1118 }
1119
1120
1121 public function fetchVariationsByIds(Request $request): array
1122 {
1123 $ids = $request->getSafe(['productIds.*' => 'intval']);
1124 $ids = is_array($ids) ? $ids : [];
1125 if (empty($ids)) {
1126 return ['products' => []];
1127 }
1128 $products = ProductVariationResource::search(["id" => ["column" => "id", "operator" => "in", "value" => is_array($ids) ? $ids : [],]], function (Builder $query) {
1129 return $query;
1130 }, true)->pluck('variation_title', 'id')->map(function ($name, $id) {
1131 return [
1132 'value' => $id,
1133 'label' => $name,
1134 ];
1135 })->values()->toArray();
1136 return ['products' => $products];
1137 }
1138
1139 public function searchProductByName(Request $request): array
1140 {
1141 $searchValue = $request->getSafe('name', 'sanitize_text_field');
1142 $urlMode = $request->getSafe('url_mode', 'sanitize_text_field');
1143 $termId = $request->getSafe('termId', 'intval');
1144
1145 $defaultFilters =
1146 [
1147 "wildcard" => $searchValue,
1148 ];
1149
1150 $status = ["post_status" => ["column" => "post_status", "operator" => "in", "value" => ["publish"]]];
1151
1152 $params = [
1153 "select" => ['id AS ID', 'post_title'],
1154 "with" => ['wpTerms'],
1155 "selected_status" => true,
1156 "status" => $status,
1157 "default_filters" => $defaultFilters,
1158 ];
1159
1160 if (!empty($termId)) {
1161 $params["taxonomy_filters"] = [
1162 'product-categories' => Arr::wrap($termId)
1163 ];
1164 }
1165
1166 $products = ShopResource::get($params);
1167 $items = $products['products'];
1168
1169 return [
1170 'products' => $items
1171 ];
1172 }
1173
1174 public function getBundleInfo(Request $request, $productId): array
1175 {
1176 $variants = ProductVariation::query()
1177 ->where('post_id', $productId)
1178 ->select([
1179 'id',
1180 'variation_title',
1181 'other_info'
1182 ])
1183 ->get()
1184 ->toArray();
1185
1186 $variants = Helper::loadBundleChild($variants);
1187
1188 return $variants;
1189 }
1190
1191 public function saveBundleInfo(Request $request, $variationId): array
1192 {
1193 $variation = ProductVariation::query()->findOrFail($variationId);
1194
1195 $childIds = $request->get('bundle_child_ids');
1196
1197 if (!empty($childIds) && is_array($childIds)) {
1198 // Reject any child variations that belong to a bundle product
1199 $bundleChildVariations = ProductVariation::whereIn('id', $childIds)
1200 ->get(['id', 'post_id']);
1201
1202 foreach ($bundleChildVariations as $childVariation) {
1203 if ($childVariation->product && $childVariation->product->isBundleProduct()) {
1204 return wp_send_json([
1205 'message' => __('A bundle product cannot be added as a bundle child.', 'fluent-cart'),
1206 ], 422);
1207 }
1208 }
1209 }
1210
1211 $otherInfo = $variation->other_info ?? [];
1212 $otherInfo['bundle_child_ids'] = $childIds;
1213 $variation->other_info = $otherInfo;
1214
1215 return [$variation->update()];
1216 }
1217
1218 public function fetchProductsByIds(Request $request): array
1219 {
1220 $ids = $request->getSafe(['productIds.*' => 'intval']);
1221
1222 $ids = Arr::get($ids, 'productIds', []);
1223 $ids = is_array($ids) ? $ids : [];
1224
1225 if (empty($ids)) {
1226 return [
1227 'products' => []
1228 ];
1229 }
1230 $products = ProductResource::search([
1231 "id" => [
1232 "column" => "id",
1233 "operator" => "in",
1234 "value" => is_array($ids) ? $ids : [],
1235 ]
1236 ], function (Builder $query) {
1237 return $query
1238 ->with('detail');
1239 }, true);
1240
1241 return [
1242 'products' => $products
1243 ];
1244 }
1245
1246 public function getMaxExcerptWordCount(): WP_REST_Response
1247 {
1248 return $this->sendSuccess([
1249 'count' => (int)apply_filters('excerpt_length', 55)
1250 ]);
1251 }
1252
1253 public function createDummyProducts(Request $request)
1254 {
1255 return DummyProductService::create($request->get('category'), $request->get('index'));
1256 }
1257
1258 public function updateInventory(Request $request, $postId, $variantId)
1259 {
1260
1261 $variant = ProductVariation::query()->find($variantId);
1262
1263 if (!$variant) {
1264 return $this->response->sendError([
1265 'message' => __('Variant not found', 'fluent-cart')
1266 ]);
1267 }
1268
1269 $detail = ProductDetail::query()->where('post_id', $postId)->first();
1270
1271 // get variations by post_id
1272 $variations = ProductVariation::query()->where('post_id', $postId)->where('id', '!=', $variantId)->get();
1273 $updateData = [];
1274 foreach ($variations as $variation) {
1275 $updateData[] = [
1276 'id' => $variation->id,
1277 'manage_stock' => 1,
1278 'total_stock' => $variation->total_stock,
1279 'available' => $variation->available,
1280 'stock_status' => $variation->stock_status
1281 ];
1282 }
1283 $updateData[] = [
1284 'id' => $variantId,
1285 'total_stock' => sanitize_text_field($request->get('total_stock')),
1286 'available' => sanitize_text_field($request->get('available')),
1287 'manage_stock' => 1,
1288 'stock_status' => $request->get('available') > 0 ? 'in-stock' : 'out-of-stock'
1289 ];
1290 // update variations
1291 $isUpdated = ProductVariation::query()->batchUpdate($updateData);
1292
1293
1294 if ($detail) {
1295 $hasAvailableStock = ProductVariation::query()->where('post_id', $postId)->where('available', '>', 0)->exists();
1296 $detail->stock_availability = $hasAvailableStock ? 'in-stock' : 'out-of-stock';
1297 $detail->manage_stock = 1;
1298 $detail->save();
1299 }
1300
1301
1302 if (is_wp_error($isUpdated)) {
1303 return $this->response->sendError([
1304 'message' => __('Inventory update failed', 'fluent-cart')
1305 ]);
1306 }
1307
1308 return $this->response->sendSuccess([
1309 'message' => __('Inventory updated successfully', 'fluent-cart')
1310 ]);
1311 }
1312
1313 public function updateManageStock(Request $request, $postId)
1314 {
1315 $manageStock = sanitize_text_field($request->get('manage_stock'));
1316
1317 $detail = ProductDetail::query()->where('post_id', $postId)->first();
1318
1319 $updateData = [
1320 'manage_stock' => $manageStock,
1321 ];
1322 if ($manageStock == 0) {
1323 $updateData['stock_status'] = 'in-stock';
1324 }
1325
1326 $updatedVariations = ProductVariation::query()->where('post_id', $postId)->update($updateData);
1327
1328 $hasAvailableStock = ProductVariation::query()->where('post_id', $postId)->where('available', '>', 0)->exists();
1329 $detail->manage_stock = $manageStock;
1330 $detail->stock_availability = $hasAvailableStock || $manageStock == 0 ? 'in-stock' : 'out-of-stock';
1331 $updatedProductDetails = $detail->save();
1332
1333 if (is_wp_error($updatedProductDetails)) {
1334 return $this->response->sendError([
1335 'message' => __('Manage stock update failed', 'fluent-cart')
1336 ]);
1337 }
1338
1339 if (is_wp_error($updatedVariations)) {
1340 return $this->response->sendError([
1341 'message' => __('Manage stock update failed', 'fluent-cart')
1342 ]);
1343 }
1344
1345 return $this->response->sendSuccess([
1346 'message' => __('Manage stock updated successfully', 'fluent-cart')
1347 ]);
1348 }
1349
1350 /**
1351 * Suggest a unique SKU based on product title and optional variant title.
1352 *
1353 * @param Request $request
1354 * @return WP_REST_Response
1355 */
1356 public function suggestSku(Request $request)
1357 {
1358 $title = sanitize_text_field($request->get('title', ''));
1359 $variantTitle = sanitize_text_field($request->get('variant_title', ''));
1360 $excludeId = absint($request->get('exclude_id', 0));
1361
1362 if (empty($title)) {
1363 return $this->sendError([
1364 'message' => __('Product title is required to generate SKU.', 'fluent-cart')
1365 ]);
1366 }
1367
1368 $sku = $this->generateSkuFromTitle($title, $variantTitle);
1369
1370 if (empty($sku)) {
1371 return $this->sendError([
1372 'message' => __('Could not generate SKU from the given title.', 'fluent-cart')
1373 ]);
1374 }
1375
1376 $sku = $this->ensureUniqueSku($sku, $excludeId);
1377
1378 return $this->sendSuccess([
1379 'sku' => $sku,
1380 ]);
1381 }
1382
1383 /**
1384 * Generate a SKU string from a product title and optional variant title.
1385 *
1386 * @param string $title
1387 * @param string $variantTitle
1388 * @return string
1389 */
1390 private function generateSkuFromTitle($title, $variantTitle = '')
1391 {
1392 $stopWords = ['the', 'and', 'for', 'with', 'a', 'an', 'of', 'in', 'on', 'to', 'is', 'it', 'by', 'or', 'at', 'from'];
1393
1394 $fullTitle = trim($title);
1395 if (!empty($variantTitle) && strtolower(trim($variantTitle)) !== strtolower(trim($title))) {
1396 $fullTitle .= ' ' . trim($variantTitle);
1397 }
1398
1399 $cleaned = strtoupper($fullTitle);
1400 $cleaned = preg_replace('/[^A-Z0-9\s]/', '', $cleaned);
1401 $words = array_values(array_filter(explode(' ', $cleaned), function ($word) use ($stopWords) {
1402 return strlen($word) > 0 && !in_array(strtolower($word), $stopWords);
1403 }));
1404
1405 if (empty($words)) {
1406 return '';
1407 }
1408
1409 $parts = array_map(function ($word) {
1410 return substr($word, 0, 3);
1411 }, $words);
1412
1413 $base = implode('-', $parts);
1414
1415 // Keep base within 25 chars to leave room for uniqueness suffix
1416 if (strlen($base) > 25) {
1417 $base = substr($base, 0, 25);
1418 $base = rtrim($base, '-');
1419 }
1420
1421 return $base;
1422 }
1423
1424 /**
1425 * Ensure the SKU is unique in the database, appending a numeric suffix if needed.
1426 *
1427 * @param string $sku
1428 * @param int|null $excludeId
1429 * @return string
1430 */
1431 private function ensureUniqueSku($sku, $excludeId = null)
1432 {
1433 $original = $sku;
1434 $suffix = 0;
1435 $batchSize = 10;
1436
1437 while ($suffix < 100) {
1438 // Build a batch of candidate SKUs to check at once
1439 $candidates = [];
1440 for ($i = $suffix; $i < $suffix + $batchSize && $i < 100; $i++) {
1441 if ($i === 0) {
1442 $candidates[] = $original;
1443 } else {
1444 $maxBaseLen = 30 - strlen('-' . $i);
1445 $candidates[] = substr($original, 0, $maxBaseLen) . '-' . $i;
1446 }
1447 }
1448
1449 $query = ProductVariation::query()->whereIn('sku', $candidates);
1450 if ($excludeId) {
1451 $query->where('id', '!=', $excludeId);
1452 }
1453 $taken = $query->get()->pluck('sku')->toArray();
1454
1455 // Return the first candidate that isn't taken
1456 foreach ($candidates as $candidate) {
1457 if (!in_array($candidate, $taken)) {
1458 return $candidate;
1459 }
1460 }
1461
1462 $suffix += $batchSize;
1463 }
1464
1465 // All candidates exhausted — fallback to timestamp-based suffix
1466 $maxBaseLen = 30 - 1 - 10; // hyphen + 10-digit timestamp
1467 return substr($original, 0, $maxBaseLen) . '-' . substr(time(), -10);
1468 }
1469
1470 /**
1471 * Fetch products formatted for bulk editing.
1472 */
1473 public function bulkEditFetch(Request $request): WP_REST_Response
1474 {
1475 try {
1476 $service = new BulkProductUpdateService();
1477 $result = $service->fetchForBulkEdit($request);
1478
1479 return $this->sendSuccess($result);
1480 } catch (\Throwable $e) {
1481 return $this->sendError([
1482 'message' => __('Failed to fetch products: ', 'fluent-cart') . $e->getMessage(),
1483 ]);
1484 }
1485 }
1486
1487 /**
1488 * Bulk update products from the bulk edit spreadsheet.
1489 */
1490 public function bulkUpdate(Request $request): WP_REST_Response
1491 {
1492 $products = $request->get('products', []);
1493
1494 if (empty($products) || !is_array($products)) {
1495 return $this->sendError([
1496 'message' => __('No products provided', 'fluent-cart'),
1497 ]);
1498 }
1499
1500 if (count($products) > 10) {
1501 return $this->sendError([
1502 'message' => __('Maximum 10 products per chunk allowed', 'fluent-cart'),
1503 ]);
1504 }
1505
1506 try {
1507 $service = new BulkProductUpdateService();
1508 $result = $service->updateChunk($products);
1509
1510 if (empty($result['updated']) && !empty($result['errors'])) {
1511 return $this->sendError([
1512 'message' => __('All products failed to update', 'fluent-cart'),
1513 'errors' => $result['errors'],
1514 ]);
1515 }
1516
1517 return $this->sendSuccess([
1518 'message' => sprintf(
1519 __('%d product(s) updated successfully', 'fluent-cart'),
1520 count($result['updated'])
1521 ),
1522 'updated' => $result['updated'],
1523 'errors' => $result['errors'],
1524 ]);
1525 } catch (\Throwable $e) {
1526 return $this->sendError([
1527 'message' => __('Bulk update failed: ', 'fluent-cart') . $e->getMessage(),
1528 ]);
1529 }
1530 }
1531 }
1532