PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / app / Http / Controllers / ProductController.php

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

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