PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.3
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.3
1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk All 48 releases
fluent-cart / app / Http / Controllers / ProductController.php

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

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