PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.5.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.5.0
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.5.0, at app/Http/Controllers/ProductController.php

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