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

1,645 lines 55.0 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 = Arr::get($ids, 'productIds', []);
1227 $ids = is_array($ids) ? $ids : [];
1228 if (empty($ids)) {
1229 return ['products' => []];
1230 }
1231 $products = ProductVariationResource::search(["id" => ["column" => "id", "operator" => "in", "value" => is_array($ids) ? $ids : [],]], function (Builder $query) {
1232 return $query;
1233 }, true)->pluck('variation_title', 'id')->map(function ($name, $id) {
1234 return [
1235 'value' => $id,
1236 'label' => $name,
1237 ];
1238 })->values()->toArray();
1239 return ['products' => $products];
1240 }
1241
1242 public function searchProductByName(Request $request): array
1243 {
1244 $searchValue = $request->getSafe('name', 'sanitize_text_field');
1245 $urlMode = $request->getSafe('url_mode', 'sanitize_text_field');
1246 $termId = $request->getSafe('termId', 'intval');
1247
1248 $defaultFilters =
1249 [
1250 "wildcard" => $searchValue,
1251 ];
1252
1253 $status = ["post_status" => ["column" => "post_status", "operator" => "in", "value" => ["publish"]]];
1254
1255 $params = [
1256 "select" => ['id AS ID', 'post_title'],
1257 "with" => ['wpTerms'],
1258 "selected_status" => true,
1259 "status" => $status,
1260 "default_filters" => $defaultFilters,
1261 ];
1262
1263 if (!empty($termId)) {
1264 $params["taxonomy_filters"] = [
1265 'product-categories' => Arr::wrap($termId)
1266 ];
1267 }
1268
1269 $products = ShopResource::get($params);
1270 $items = $products['products'];
1271
1272 return [
1273 'products' => $items
1274 ];
1275 }
1276
1277 public function getBundleInfo(Request $request, $productId): array
1278 {
1279 $variants = ProductVariation::query()
1280 ->where('post_id', $productId)
1281 ->select([
1282 'id',
1283 'variation_title',
1284 'other_info'
1285 ])
1286 ->get()
1287 ->toArray();
1288
1289 $variants = Helper::loadBundleChild($variants);
1290
1291 return $variants;
1292 }
1293
1294 public function saveBundleInfo(Request $request, $variationId): array
1295 {
1296 $variation = ProductVariation::query()->findOrFail($variationId);
1297
1298 $childIds = $request->get('bundle_child_ids');
1299
1300 if (!empty($childIds) && is_array($childIds)) {
1301 // Reject any child variations that belong to a bundle product
1302 $bundleChildVariations = ProductVariation::whereIn('id', $childIds)
1303 ->get(['id', 'post_id']);
1304
1305 foreach ($bundleChildVariations as $childVariation) {
1306 if ($childVariation->product && $childVariation->product->isBundleProduct()) {
1307 return wp_send_json([
1308 'message' => __('A bundle product cannot be added as a bundle child.', 'fluent-cart'),
1309 ], 422);
1310 }
1311 }
1312 }
1313
1314 $otherInfo = $variation->other_info ?? [];
1315 $otherInfo['bundle_child_ids'] = $childIds;
1316 $variation->other_info = $otherInfo;
1317
1318 return [$variation->update()];
1319 }
1320
1321 public function fetchProductsByIds(Request $request): array
1322 {
1323 $ids = $request->getSafe(['productIds.*' => 'intval']);
1324
1325 $ids = Arr::get($ids, 'productIds', []);
1326 $ids = is_array($ids) ? $ids : [];
1327
1328 if (empty($ids)) {
1329 return [
1330 'products' => []
1331 ];
1332 }
1333 $products = ProductResource::search([
1334 "id" => [
1335 "column" => "id",
1336 "operator" => "in",
1337 "value" => is_array($ids) ? $ids : [],
1338 ]
1339 ], function (Builder $query) {
1340 return $query
1341 ->with('detail');
1342 }, true);
1343
1344 return [
1345 'products' => $products
1346 ];
1347 }
1348
1349 public function getMaxExcerptWordCount(): WP_REST_Response
1350 {
1351 return $this->sendSuccess([
1352 'count' => (int)apply_filters('excerpt_length', 55)
1353 ]);
1354 }
1355
1356 public function createDummyProducts(Request $request)
1357 {
1358 return DummyProductService::create($request->get('category'), $request->get('index'));
1359 }
1360
1361 public function updateInventory(Request $request, $postId, $variantId)
1362 {
1363
1364 $variant = ProductVariation::query()->find($variantId);
1365
1366 if (!$variant) {
1367 return $this->response->sendError([
1368 'message' => __('Variant not found', 'fluent-cart')
1369 ]);
1370 }
1371
1372 // Capture old stock state before update
1373 $oldAvailable = intval($variant->available);
1374 $oldStockStatus = $variant->stock_status;
1375
1376 $detail = ProductDetail::query()->where('post_id', $postId)->first();
1377
1378 // get variations by post_id
1379 $variations = ProductVariation::query()->where('post_id', $postId)->where('id', '!=', $variantId)->get();
1380 $updateData = [];
1381 foreach ($variations as $variation) {
1382 $updateData[] = [
1383 'id' => $variation->id,
1384 'manage_stock' => 1,
1385 'total_stock' => $variation->total_stock,
1386 'available' => $variation->available,
1387 'stock_status' => $variation->stock_status
1388 ];
1389 }
1390 $newAvailable = intval($request->get('available'));
1391 $newStockStatus = $newAvailable > 0 ? 'in-stock' : 'out-of-stock';
1392
1393 $updateData[] = [
1394 'id' => $variantId,
1395 'total_stock' => sanitize_text_field($request->get('total_stock')),
1396 'available' => $newAvailable,
1397 'manage_stock' => 1,
1398 'stock_status' => $newStockStatus
1399 ];
1400 // update variations
1401 $isUpdated = ProductVariation::query()->batchUpdate($updateData);
1402
1403
1404 if ($detail) {
1405 $hasAvailableStock = ProductVariation::query()->where('post_id', $postId)->where('available', '>', 0)->exists();
1406 $detail->stock_availability = $hasAvailableStock ? 'in-stock' : 'out-of-stock';
1407 $detail->manage_stock = 1;
1408 $detail->save();
1409 }
1410 if (is_wp_error($isUpdated)) {
1411 return $this->response->sendError([
1412 'message' => __('Inventory update failed', 'fluent-cart')
1413 ]);
1414 }
1415
1416 // Stock persisted — fire StockChanged only if it actually changed.
1417 if ($oldAvailable !== $newAvailable || $oldStockStatus !== $newStockStatus) {
1418 (new StockChanged([$postId]))->dispatch();
1419 }
1420
1421 return $this->response->sendSuccess([
1422 'message' => __('Inventory updated successfully', 'fluent-cart')
1423 ]);
1424 }
1425
1426 public function updateManageStock(Request $request, $postId)
1427 {
1428 $manageStock = sanitize_text_field($request->get('manage_stock'));
1429
1430 $detail = ProductDetail::query()->where('post_id', $postId)->first();
1431
1432 $updateData = [
1433 'manage_stock' => $manageStock,
1434 ];
1435 if ($manageStock == 0) {
1436 $updateData['stock_status'] = 'in-stock';
1437 }
1438
1439 $updatedVariations = ProductVariation::query()->where('post_id', $postId)->update($updateData);
1440
1441 $hasAvailableStock = ProductVariation::query()->where('post_id', $postId)->where('available', '>', 0)->exists();
1442 $detail->manage_stock = $manageStock;
1443 $detail->stock_availability = $hasAvailableStock || $manageStock == 0 ? 'in-stock' : 'out-of-stock';
1444 $updatedProductDetails = $detail->save();
1445
1446 if (is_wp_error($updatedProductDetails)) {
1447 return $this->response->sendError([
1448 'message' => __('Manage stock update failed', 'fluent-cart')
1449 ]);
1450 }
1451
1452 if (is_wp_error($updatedVariations)) {
1453 return $this->response->sendError([
1454 'message' => __('Manage stock update failed', 'fluent-cart')
1455 ]);
1456 }
1457
1458 return $this->response->sendSuccess([
1459 'message' => __('Manage stock updated successfully', 'fluent-cart')
1460 ]);
1461 }
1462
1463 /**
1464 * Suggest a unique SKU based on product title and optional variant title.
1465 *
1466 * @param Request $request
1467 * @return WP_REST_Response
1468 */
1469 public function suggestSku(Request $request)
1470 {
1471 $title = sanitize_text_field($request->get('title', ''));
1472 $variantTitle = sanitize_text_field($request->get('variant_title', ''));
1473 $excludeId = absint($request->get('exclude_id', 0));
1474
1475 if (empty($title)) {
1476 return $this->sendError([
1477 'message' => __('Product title is required to generate SKU.', 'fluent-cart')
1478 ]);
1479 }
1480
1481 $sku = $this->generateSkuFromTitle($title, $variantTitle);
1482
1483 if (empty($sku)) {
1484 return $this->sendError([
1485 'message' => __('Could not generate SKU from the given title.', 'fluent-cart')
1486 ]);
1487 }
1488
1489 $sku = $this->ensureUniqueSku($sku, $excludeId);
1490
1491 return $this->sendSuccess([
1492 'sku' => $sku,
1493 ]);
1494 }
1495
1496 /**
1497 * Generate a SKU string from a product title and optional variant title.
1498 *
1499 * @param string $title
1500 * @param string $variantTitle
1501 * @return string
1502 */
1503 private function generateSkuFromTitle($title, $variantTitle = '')
1504 {
1505 $stopWords = ['the', 'and', 'for', 'with', 'a', 'an', 'of', 'in', 'on', 'to', 'is', 'it', 'by', 'or', 'at', 'from'];
1506
1507 $fullTitle = trim($title);
1508 if (!empty($variantTitle) && strtolower(trim($variantTitle)) !== strtolower(trim($title))) {
1509 $fullTitle .= ' ' . trim($variantTitle);
1510 }
1511
1512 $cleaned = strtoupper($fullTitle);
1513 $cleaned = preg_replace('/[^A-Z0-9\s]/', '', $cleaned);
1514 $words = array_values(array_filter(explode(' ', $cleaned), function ($word) use ($stopWords) {
1515 return strlen($word) > 0 && !in_array(strtolower($word), $stopWords);
1516 }));
1517
1518 if (empty($words)) {
1519 return '';
1520 }
1521
1522 $parts = array_map(function ($word) {
1523 return substr($word, 0, 3);
1524 }, $words);
1525
1526 $base = implode('-', $parts);
1527
1528 // Keep base within 25 chars to leave room for uniqueness suffix
1529 if (strlen($base) > 25) {
1530 $base = substr($base, 0, 25);
1531 $base = rtrim($base, '-');
1532 }
1533
1534 return $base;
1535 }
1536
1537 /**
1538 * Ensure the SKU is unique in the database, appending a numeric suffix if needed.
1539 *
1540 * @param string $sku
1541 * @param int|null $excludeId
1542 * @return string
1543 */
1544 private function ensureUniqueSku($sku, $excludeId = null)
1545 {
1546 $original = $sku;
1547 $suffix = 0;
1548 $batchSize = 10;
1549
1550 while ($suffix < 100) {
1551 // Build a batch of candidate SKUs to check at once
1552 $candidates = [];
1553 for ($i = $suffix; $i < $suffix + $batchSize && $i < 100; $i++) {
1554 if ($i === 0) {
1555 $candidates[] = $original;
1556 } else {
1557 $maxBaseLen = 30 - strlen('-' . $i);
1558 $candidates[] = substr($original, 0, $maxBaseLen) . '-' . $i;
1559 }
1560 }
1561
1562 $query = ProductVariation::query()->whereIn('sku', $candidates);
1563 if ($excludeId) {
1564 $query->where('id', '!=', $excludeId);
1565 }
1566 $taken = $query->get()->pluck('sku')->toArray();
1567
1568 // Return the first candidate that isn't taken
1569 foreach ($candidates as $candidate) {
1570 if (!in_array($candidate, $taken)) {
1571 return $candidate;
1572 }
1573 }
1574
1575 $suffix += $batchSize;
1576 }
1577
1578 // All candidates exhausted — fallback to timestamp-based suffix
1579 $maxBaseLen = 30 - 1 - 10; // hyphen + 10-digit timestamp
1580 return substr($original, 0, $maxBaseLen) . '-' . substr(time(), -10);
1581 }
1582
1583 /**
1584 * Fetch products formatted for bulk editing.
1585 */
1586 public function bulkEditFetch(Request $request): WP_REST_Response
1587 {
1588 try {
1589 $service = new BulkProductUpdateService();
1590 $result = $service->fetchForBulkEdit($request);
1591
1592 return $this->sendSuccess($result);
1593 } catch (\Throwable $e) {
1594 return $this->sendError([
1595 'message' => __('Failed to fetch products: ', 'fluent-cart') . $e->getMessage(),
1596 ]);
1597 }
1598 }
1599
1600 /**
1601 * Bulk update products from the bulk edit spreadsheet.
1602 */
1603 public function bulkUpdate(Request $request): WP_REST_Response
1604 {
1605 $products = $request->get('products', []);
1606
1607 if (empty($products) || !is_array($products)) {
1608 return $this->sendError([
1609 'message' => __('No products provided', 'fluent-cart'),
1610 ]);
1611 }
1612
1613 if (count($products) > 10) {
1614 return $this->sendError([
1615 'message' => __('Maximum 10 products per chunk allowed', 'fluent-cart'),
1616 ]);
1617 }
1618
1619 try {
1620 $service = new BulkProductUpdateService();
1621 $result = $service->updateChunk($products);
1622
1623 if (empty($result['updated']) && !empty($result['errors'])) {
1624 return $this->sendError([
1625 'message' => __('All products failed to update', 'fluent-cart'),
1626 'errors' => $result['errors'],
1627 ]);
1628 }
1629
1630 return $this->sendSuccess([
1631 'message' => sprintf(
1632 __('%d product(s) updated successfully', 'fluent-cart'),
1633 count($result['updated'])
1634 ),
1635 'updated' => $result['updated'],
1636 'errors' => $result['errors'],
1637 ]);
1638 } catch (\Throwable $e) {
1639 return $this->sendError([
1640 'message' => __('Bulk update failed: ', 'fluent-cart') . $e->getMessage(),
1641 ]);
1642 }
1643 }
1644 }
1645