PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk 1.2.0 All 47 releases
fluent-cart / api / Resource / ShopResource.php

ShopResource.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at api/Resource/ShopResource.php

554 lines 21.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\Api\Resource;
4
5 use FluentCart\App\Helpers\Helper;
6 use FluentCart\App\Models\Product;
7 use FluentCart\App\Models\ProductDetail;
8 use FluentCart\App\Models\WpModels\TermRelationship;
9 use FluentCart\Framework\Database\Orm\Builder;
10 use FluentCart\Framework\Support\Arr;
11 use FluentCart\Framework\Support\Str;
12
13 class ShopResource extends BaseResourceApi
14 {
15
16 public static function getQuery(): Builder
17 {
18 return Product::query();
19 }
20
21 /**
22 * Get product lists with specified filters.
23 *
24 * @param array $params Array containing the necessary parameters.
25 *
26 * $params = [
27 * 'filters' => (array) Optional. Additional filters for product retrieval
28 * [
29 * 'wildcard' => (string) Optional. Wildcard for filtering by name,
30 * 'enable_wildcard_for_post_content' => (int) Optional. Filter for post content,
31 * 'categories' => (array) Optional. Filter by Category,
32 * 'price_range_from' => (float) Optional. Minimum price for filtering,
33 * 'price_range_to' => (float) Optional. Maximum price for filtering
34 * ]
35 * 'selected_status' => (bool) Optional. Whether to filter by selected status for Shop only,
36 * 'status' => (array) Optional.
37 * [ "post_status" => [
38 * "column" => "post_status",
39 * "operator" => "(string)",
40 * "value" => (string|array) ]
41 * ],
42 * 'term_ids_for_filter' => (array) Optional. IDs for filtering by category or tag,
43 * 'select' => (string|array) Optional. Columns to select in the query,
44 * 'with' => (an array) Optional. Relationships name to be eager loaded,
45 * "admin_all_statuses" => (array) Optional.
46 * [ "post_status" => [
47 * "column" => "post_status",
48 * "operator" => "(string)",
49 * "value" => (string|array) ]
50 * ],
51 * "admin_search" => (array) Optional.
52 * [ "post_title" => [
53 * "column" => "post_title",
54 * "operator" => "(string)",
55 * "value" => (string|array) ]
56 * ],
57 * "admin_filters" => (array)Optional.
58 * [ "column name" => [
59 * "column" => "column name",
60 * "operator" => "(string)",
61 * "value" => (string|array)]
62 * ],
63 * 'order_by' => (string) Optional. Column to order by,
64 * 'order_type' => (string) Optional. Order type for sorting (ASC or DESC),
65 * 'per_page' => (int) Optional. Number of items for per page,
66 * 'page' => (int) Optional. Page number for pagination
67 * ];
68 */
69 public static function get(array $params = []): array
70 {
71 $shopAppDefaultFilters = Arr::get($params, 'shop_app_default_filters');
72 $defaultFilters = Arr::get($params, 'default_filters', []);
73 $filters = Arr::get($params, 'filters', []);
74
75 // @TODO: move two below check to appropriate place after checking
76 if (is_string($filters)) {
77 $filters = json_decode($filters, true) ?: [];
78 }
79 if (is_string($defaultFilters)) {
80 $defaultFilters = json_decode($defaultFilters, true) ?: [];
81 }
82
83 $taxonomy_filters = Arr::get($params, 'taxonomy_filters', []);
84
85
86 $defaultWildcard = Arr::get($defaultFilters, 'wildcard', null);
87 $wildcard = Arr::get($filters, 'wildcard', null);
88
89 $status = Arr::get($params, 'status');
90
91 $adminSearch = Arr::get($params, 'admin_search', null);
92 $adminFilters = Arr::get($params, 'admin_filters', []);
93 $excludedId = Arr::get($params, 'excluded_id');
94
95
96 $query = static::getQuery()
97 ->select(Arr::get($params, 'select', '*'))
98 ->with(static::expandAppendRelations(Arr::get($params, 'with', [])));
99
100 $query = apply_filters('fluent_cart/shop_query', $query, $params);
101
102 $query = $query->when(!Arr::get($params, 'selected_status'), function ($query) use ($params) {
103 return $query->search(Arr::get($params, 'admin_all_statuses', []));
104 })
105 ->when($adminSearch, function ($query) use ($adminSearch) {
106 return $query->search([
107 'post_title' => [
108 'column' => 'post_title',
109 'operator' => 'like_all',
110 'value' => $adminSearch
111 ]
112 ])
113 ->orWhere('ID', 'like', '%' . $adminSearch . '%')
114 ->orWhereHas('detail', function ($detailQuery) use ($adminSearch) {
115 $detailQuery->where('fulfillment_type', 'like', '%' . $adminSearch . '%');
116 });
117 })
118 //Handel default wildcard
119 ->when($defaultWildcard, function ($query) use ($defaultWildcard) {
120 return $query->search(["post_title" => ["column" => "post_title", "operator" => "like_all", "value" => $defaultWildcard]]);
121 })
122 ->when($wildcard, function ($query) use ($wildcard, $filters) {
123 return $query->search(["post_title" => ["column" => "post_title", "operator" => "like_all", "value" => $wildcard]])
124 ->when(Arr::get($filters, 'enable_wildcard_for_post_content', 0), function ($query) use ($wildcard) {
125 return $query->search(["post_content" => ["column" => "post_content", "operator" => "or_like_all", "value" => $wildcard]]);
126 });
127 })
128 ->when(Arr::get($shopAppDefaultFilters, 'enabled', 0), function ($query) use ($shopAppDefaultFilters) {
129 return $query->when(Arr::get($shopAppDefaultFilters, 'wildcard'), function ($query) use ($shopAppDefaultFilters) {
130 return $query->where(function ($query) use ($shopAppDefaultFilters) {
131 return $query->search(["post_title" => ["column" => "post_title", "operator" => "like_all", "value" => $shopAppDefaultFilters['wildcard']]]);
132 });
133 });
134 })
135 ->when(!empty($filters['price_range_from']) && !empty($filters['price_range_to']), function ($query) use ($filters) {
136 return $query->whereHas('detail', function ($query) use ($filters) {
137 return $query->search(["min_price" => ["column" => "min_price", "operator" => "between", "value" => [Helper::toCent($filters['price_range_from']), Helper::toCent($filters['price_range_to'])]]]);
138 });
139 })
140 ->when(!empty($taxonomy_filters), function ($query) use ($taxonomy_filters) {
141
142 //or filter
143 foreach ($taxonomy_filters as $taxonomy => $terms) {
144 $query->whereHas('wpTerms', function ($query) use ($terms) {
145 return $query->search(["term_id" => ["column" => "term_id", "operator" => "in", "value" => $terms]]);
146 });
147 }
148 })
149 // Shortcode filter: include specific product IDs
150 ->when(!empty(Arr::get($params, 'include_ids')), function ($query) use ($params) {
151 return $query->whereIn('ID', Arr::get($params, 'include_ids'));
152 })
153 // Shortcode filter: exclude specific product IDs
154 ->when(!empty(Arr::get($params, 'exclude_ids')), function ($query) use ($params) {
155 return $query->whereNotIn('ID', Arr::get($params, 'exclude_ids'));
156 })
157 // Shortcode filter: product type (unified: fulfillment_type, payment_type on variants; variation_type on detail)
158 ->when(!empty(Arr::get($params, 'product_type')), function ($query) use ($params) {
159 $type = Arr::get($params, 'product_type');
160 if (in_array($type, ['physical', 'digital'])) {
161 return $query->whereHas('variants', function ($q) use ($type) {
162 $q->where('fulfillment_type', $type);
163 });
164 }
165 if (in_array($type, ['subscription', 'onetime'])) {
166 return $query->whereHas('variants', function ($q) use ($type) {
167 $q->where('payment_type', $type);
168 });
169 }
170 if (in_array($type, ['simple', 'simple_variations'])) {
171 return $query->whereHas('detail', function ($q) use ($type) {
172 $q->where('variation_type', $type);
173 });
174 }
175 return $query;
176 })
177 // Shortcode filter: on sale
178 ->when(!empty(Arr::get($params, 'on_sale')), function ($query) {
179 return $query->whereHas('variants', function ($q) {
180 $q->where('compare_price', '>', 0)
181 ->whereRaw('item_price < compare_price');
182 });
183 })
184 ->when($adminFilters, function ($query) use ($adminFilters) {
185 return $query->whereHas('detail', function ($query) use ($adminFilters) {
186 return $query->search($adminFilters);
187 });
188 })
189 ->when($excludedId, function ($query) use ($excludedId) {
190 return $query->search($excludedId);
191 })
192 ->when($status, function ($query) use ($status) {
193 return $query->search($status);
194 });
195
196 $totalCount = $query->cloneWithout(['columns', 'orders', 'limit', 'offset', 'joins', 'lock', 'union'])->cloneWithoutBindings(['order'])->count('*');
197
198
199 // --- Sorting
200 $sortBy = Arr::get($filters, 'sort_by', 'name-asc');
201
202 $sortMapping = [
203 'name-asc' => ['column' => 'post_title', 'order' => 'ASC'],
204 'name-desc' => ['column' => 'post_title', 'order' => 'DESC'],
205 'price-low' => ['column' => 'min_price', 'order' => 'ASC'],
206 'price-high' => ['column' => 'min_price', 'order' => 'DESC'],
207 'date-newest' => ['column' => 'ID', 'order' => 'DESC'],
208 'date-oldest' => ['column' => 'ID', 'order' => 'ASC'],
209 ];
210
211 // $orderBy = Arr::get($params, 'order_by', 'ID');
212 // $orderType = Arr::get($params, 'order_type', 'ASC');
213
214 // Apply sorting
215 if ($mapping = Arr::get($sortMapping, $sortBy)) {
216 $sortColumn = Arr::get($mapping, 'column');
217 $sortOrder = Arr::get($mapping, 'order');
218
219 // Sorting by price - use COALESCE to handle NULL min_price for cursor pagination
220 if ($sortColumn == 'min_price') {
221 $query->leftJoin('fct_product_details as pd', 'posts.ID', '=', 'pd.post_id')
222 ->select('posts.*')
223 ->selectRaw('COALESCE(pd.min_price, 0) as sort_price')
224 ->orderBy('sort_price', $sortOrder)
225 ->orderBy('posts.ID', 'ASC');
226 } elseif ($sortColumn == 'post_title') {
227 //Extract number from start of title (e.g., "30 Day Retreat" → 30)
228 global $wpdb;
229 $postTable = $wpdb->prefix . 'posts';
230
231 // SQLite-compatible substring extraction
232 $isSqlite = defined('DB_ENGINE') && DB_ENGINE === 'sqlite';
233 if ($isSqlite) {
234 // SQLite: Use a simpler approach to avoid parsing issues
235 // First extract the number part, then sort by it
236 $query = $query->selectRaw("
237 CASE
238 WHEN INSTR($postTable.post_title, ' ') > 0
239 THEN SUBSTR($postTable.post_title, 1, INSTR($postTable.post_title, ' ') - 1)
240 ELSE $postTable.post_title
241 END AS title_number
242 ")->orderByRaw("title_number $sortOrder, $postTable.post_title $sortOrder");
243 } else {
244 // MySQL: Use SUBSTRING_INDEX
245 $query = $query->orderByRaw("
246 CAST(SUBSTRING_INDEX($postTable.post_title, ' ', 1) AS UNSIGNED) $sortOrder
247 ")->orderBy('posts.post_title', $sortOrder);
248 }
249
250 if (Arr::get($params, 'paginate_using') === 'cursor') {
251 $query = $query->orderBy("posts.ID", 'ASC');
252 }
253
254 } else {
255 $query = $query->orderBy($sortColumn, $sortOrder);
256 }
257 }
258
259 if (Arr::get($params, 'paginate_using') === 'cursor') {
260 $products = $query->cursorPaginate(Arr::get($params, 'per_page', 10), ['*'], 'cursor', Arr::get($params, 'cursor'));
261 } else {
262 $products = $query->simplePaginate(Arr::get($params, 'per_page', 10), ['*'], 'current_page', Arr::get($params, 'page'));
263 }
264
265 static::primePostCaches($products);
266
267 return [
268 'products' => $products,
269 'total' => $totalCount
270 ];
271 }
272
273 /**
274 * Expand the caller's `with` list so the relations that the models' default
275 * appends read are eager loaded rather than fetched one row at a time.
276 *
277 * `ProductVariation::$appends` renders `thumbnail` from its `media` relation and
278 * `ProductDetail::$appends` renders `featured_media` from `galleryImage`, so every
279 * serialized row issued its own query — one per variant plus one per product, on a
280 * public list endpoint. Only relations the caller already asked for are expanded,
281 * so the response shape is byte-identical; callers that never load `variants` or
282 * `detail` are untouched.
283 *
284 * @param array|string $with Relations the caller requested.
285 * @return array
286 */
287 protected static function expandAppendRelations($with): array
288 {
289 $with = Arr::wrap($with);
290
291 $appendRelations = [
292 'variants' => 'variants.media',
293 'detail' => 'detail.galleryImage',
294 ];
295
296 foreach ($appendRelations as $relation => $nestedRelation) {
297 if (in_array($relation, $with, true) && !in_array($nestedRelation, $with, true)) {
298 $with[] = $nestedRelation;
299 }
300 }
301
302 return $with;
303 }
304
305 /**
306 * Prime the WordPress post cache for the products on this page.
307 *
308 * These rows come from the ORM, not WP_Query, so nothing populates the `posts`
309 * cache. Every `get_permalink()` behind the `view_url` append — and every core
310 * template helper a storefront card calls — then fell through to `get_post()`,
311 * one SELECT per product. This replaces them with a single `IN (...)` read.
312 * Terms and meta are deliberately not primed: no product-list path reads them
313 * here, and priming them would cost two more queries.
314 *
315 * @param mixed $products Paginator returned by the list query.
316 * @return void
317 */
318 protected static function primePostCaches($products)
319 {
320 if (!$products || !method_exists($products, 'getCollection')) {
321 return;
322 }
323
324 $postIds = $products->getCollection()->pluck('ID')->filter()->all();
325
326 if ($postIds) {
327 _prime_post_caches($postIds, false, false);
328 }
329 }
330
331 /**
332 * Find product by its ID.
333 *
334 * @param int $productId The ID of the post.
335 * @param array $data Additional data for finding product (optional).
336 *
337 */
338 public static function find($productId, $data = []): ?array
339 {
340 $product = static::getQuery()
341 ->with('postmeta')
342 ->with('detail')
343 ->with('licensesMeta')
344 ->with(['variants' => function ($query) {
345 $query->with('media')->orderBy('serial_index', 'ASC');
346 }])
347 ->where('id', $productId)->first();
348
349 if (empty($product)) {
350 return null;
351 }
352
353 //Below lines are required
354 $product->view_url = $product->view_url;
355 $product->edit_url = $product->edit_url;
356 $product->featured_media = $product->featured_media;
357
358 return $product->toArray();
359 }
360
361 /**
362 * Retrieve similar product by its ID.
363 *
364 * @param int $id The ID of the post.
365 *
366 */
367 public static function getSimilarProducts($id, $asArray = true, $config = [])
368 {
369 $post = get_post($id);
370
371 if (!$post) {
372 return [];
373 }
374
375 $relatedBy = Arr::get($config, 'related_by');
376 $orderBy = Arr::get($config, 'order_by', 'title_asc');
377 $postsPerPage = (int) Arr::get($config, 'posts_per_page', 6);
378 $postsPerPage = max(1, min($postsPerPage, 24));
379
380 $taxQuery = static::buildTaxQuery($id, $post->post_type, $relatedBy);
381
382 if (!$taxQuery) {
383 return [];
384 }
385
386 [$orderField, $orderDir] = static::parseOrderBy($orderBy);
387
388 $args = [
389 'post_type' => $post->post_type,
390 'post_status' => 'publish',
391 'posts_per_page' => $postsPerPage,
392 'post__not_in' => [$id],
393 'tax_query' => $taxQuery,
394 'fields' => 'ids'
395 ];
396
397 // Price ordering needs custom SQL
398 $priceFilter = null;
399 if ($orderField === 'price') {
400 $priceFilter = static::applyPriceOrdering($orderDir);
401 } else {
402 $args['orderby'] = $orderField;
403 $args['order'] = $orderDir;
404 }
405
406 // Filters the query arguments used to fetch related products.
407 // Developers can customize the query as needed, such as excluding products
408 // or modifying the query parameters.
409 $args = apply_filters(
410 'fluent_cart/related_products/query_args',
411 $args,
412 [
413 'product_id' => $id,
414 'post' => $post,
415 'config' => $config,
416 ]
417 );
418
419 $query = new \WP_Query($args);
420
421 // Remove the price filter if applied
422 if ($priceFilter) {
423 remove_filter('posts_clauses', $priceFilter);
424 }
425
426 if (empty($query->posts)) {
427 return [];
428 }
429
430 $results = [];
431
432 foreach ($query->posts as $postId) {
433
434 // Convert WP Post → Product Model
435 $similarProduct = static::getQuery()
436 ->with(['postmeta', 'detail', 'detail.galleryImage'])
437 ->find($postId);
438
439 if ($similarProduct) {
440 $similarProduct->setAppends(['view_url', 'edit_url', 'thumbnail']);
441 $results[] = $similarProduct;
442 }
443 }
444
445 // Return array or objects
446 if ($asArray) {
447 return array_map(fn ($product) => $product->toArray(), $results);
448 }
449
450 return $results;
451 }
452
453 private static function applyPriceOrdering(string $orderDir): \Closure
454 {
455 global $wpdb;
456 $detailsTable = $wpdb->prefix . 'fct_product_details';
457 $postsTable = $wpdb->posts;
458
459 $orderDir = strtoupper($orderDir);
460 $orderDir = in_array($orderDir, ['ASC', 'DESC'], true) ? $orderDir : 'ASC';
461
462 $filter = function ($clauses) use ($detailsTable, $postsTable, $orderDir) {
463 // Prevent duplicate join
464 if (strpos($clauses['join'], $detailsTable) === false) {
465 $clauses['join'] .= " LEFT JOIN {$detailsTable} ON {$detailsTable}.post_id = {$postsTable}.ID";
466 }
467
468 $clauses['orderby'] = "{$detailsTable}.min_price {$orderDir}";
469 return $clauses;
470 };
471
472 add_filter('posts_clauses', $filter);
473
474 return $filter;
475 }
476
477 private static function buildTaxQuery($productId, $postType, $relatedBy): ?array
478 {
479 // null = no filter, use all taxonomies
480 // empty array = filters provided but none selected, return empty
481 if (is_array($relatedBy) && empty($relatedBy)) {
482 return null;
483 }
484
485 $taxonomies = $relatedBy ?: get_object_taxonomies($postType);
486 $termIds = [];
487
488 foreach ($taxonomies as $taxonomy) {
489 $terms = wp_get_post_terms($productId, $taxonomy, ['fields' => 'ids']);
490 if ($terms) {
491 $termIds[$taxonomy] = $terms;
492 }
493 }
494
495 if (!$termIds) {
496 return null;
497 }
498
499 $taxQuery = ['relation' => 'OR'];
500 foreach ($termIds as $taxonomy => $ids) {
501 $taxQuery[] = [
502 'taxonomy' => $taxonomy,
503 'field' => 'term_id',
504 'terms' => $ids,
505 'operator' => 'IN',
506 ];
507 }
508
509 return $taxQuery;
510 }
511
512 private static function parseOrderBy(string $orderBy): array
513 {
514 // Parse combined value like "date_desc", "title_asc", "price_desc", "rand"
515 $allowedFields = ['date', 'title', 'price', 'rand'];
516 $allowedOrders = ['asc', 'desc'];
517
518 $field = 'title';
519 $dir = 'ASC';
520
521 if ($orderBy === 'rand') {
522 return ['rand', 'ASC'];
523 }
524
525 if (strpos($orderBy, '_') !== false) {
526 [$f, $d] = explode('_', $orderBy, 2);
527
528 if (in_array($f, $allowedFields, true)) {
529 $field = $f;
530 }
531
532 if (in_array($d, $allowedOrders, true)) {
533 $dir = strtoupper($d);
534 }
535 }
536
537 return [$field, $dir];
538 }
539
540 public static function create($data, $params = [])
541 {
542 }
543
544 public static function update($productDetail, $postId, $params = [])
545 {
546
547 }
548
549 public static function delete($detailId, $params = [])
550 {
551
552 }
553 }
554