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

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