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

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

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