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 / api / Resource / ShopResource.php

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

481 lines 18.7 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(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 return [
266 'products' => $products,
267 'total' => $totalCount
268 ];
269 }
270
271 /**
272 * Find product by its ID.
273 *
274 * @param int $productId The ID of the post.
275 * @param array $data Additional data for finding product (optional).
276 *
277 */
278 public static function find($productId, $data = []): ?array
279 {
280 $product = static::getQuery()
281 ->with('postmeta')
282 ->with('detail')
283 ->with('licensesMeta')
284 ->with(['variants' => function ($query) {
285 $query->with('media')->orderBy('serial_index', 'ASC');
286 }])
287 ->where('id', $productId)->first();
288
289 if (empty($product)) {
290 return null;
291 }
292
293 //Below lines are required
294 $product->view_url = $product->view_url;
295 $product->edit_url = $product->edit_url;
296 $product->featured_media = $product->featured_media;
297
298 return $product->toArray();
299 }
300
301 /**
302 * Retrieve similar product by its ID.
303 *
304 * @param int $id The ID of the post.
305 *
306 */
307 public static function getSimilarProducts($id, $asArray = true, $config = [])
308 {
309 $post = get_post($id);
310
311 if (!$post) {
312 return [];
313 }
314
315 $relatedBy = Arr::get($config, 'related_by');
316 $orderBy = Arr::get($config, 'order_by', 'title_asc');
317 $postsPerPage = (int) Arr::get($config, 'posts_per_page', 6);
318 $postsPerPage = max(1, min($postsPerPage, 24));
319
320 $taxQuery = static::buildTaxQuery($id, $post->post_type, $relatedBy);
321
322 if (!$taxQuery) {
323 return [];
324 }
325
326 [$orderField, $orderDir] = static::parseOrderBy($orderBy);
327
328 $args = [
329 'post_type' => $post->post_type,
330 'post_status' => 'publish',
331 'posts_per_page' => $postsPerPage,
332 'post__not_in' => [$id],
333 'tax_query' => $taxQuery,
334 'fields' => 'ids'
335 ];
336
337 // Price ordering needs custom SQL
338 $priceFilter = null;
339 if ($orderField === 'price') {
340 $priceFilter = static::applyPriceOrdering($orderDir);
341 } else {
342 $args['orderby'] = $orderField;
343 $args['order'] = $orderDir;
344 }
345
346 $query = new \WP_Query($args);
347
348 // Remove the price filter if applied
349 if ($priceFilter) {
350 remove_filter('posts_clauses', $priceFilter);
351 }
352
353 if (empty($query->posts)) {
354 return [];
355 }
356
357 $results = [];
358
359 foreach ($query->posts as $postId) {
360
361 // Convert WP Post → Product Model
362 $similarProduct = static::getQuery()
363 ->with(['postmeta', 'detail', 'detail.galleryImage'])
364 ->find($postId);
365
366 if ($similarProduct) {
367 $similarProduct->setAppends(['view_url', 'edit_url', 'thumbnail']);
368 $results[] = $similarProduct;
369 }
370 }
371
372 // Return array or objects
373 if ($asArray) {
374 return array_map(fn ($product) => $product->toArray(), $results);
375 }
376
377 return $results;
378 }
379
380 private static function applyPriceOrdering(string $orderDir): \Closure
381 {
382 global $wpdb;
383 $detailsTable = $wpdb->prefix . 'fct_product_details';
384 $postsTable = $wpdb->posts;
385
386 $orderDir = strtoupper($orderDir);
387 $orderDir = in_array($orderDir, ['ASC', 'DESC'], true) ? $orderDir : 'ASC';
388
389 $filter = function ($clauses) use ($detailsTable, $postsTable, $orderDir) {
390 // Prevent duplicate join
391 if (strpos($clauses['join'], $detailsTable) === false) {
392 $clauses['join'] .= " LEFT JOIN {$detailsTable} ON {$detailsTable}.post_id = {$postsTable}.ID";
393 }
394
395 $clauses['orderby'] = "{$detailsTable}.min_price {$orderDir}";
396 return $clauses;
397 };
398
399 add_filter('posts_clauses', $filter);
400
401 return $filter;
402 }
403
404 private static function buildTaxQuery($productId, $postType, $relatedBy): ?array
405 {
406 // null = no filter, use all taxonomies
407 // empty array = filters provided but none selected, return empty
408 if (is_array($relatedBy) && empty($relatedBy)) {
409 return null;
410 }
411
412 $taxonomies = $relatedBy ?: get_object_taxonomies($postType);
413 $termIds = [];
414
415 foreach ($taxonomies as $taxonomy) {
416 $terms = wp_get_post_terms($productId, $taxonomy, ['fields' => 'ids']);
417 if ($terms) {
418 $termIds[$taxonomy] = $terms;
419 }
420 }
421
422 if (!$termIds) {
423 return null;
424 }
425
426 $taxQuery = ['relation' => 'OR'];
427 foreach ($termIds as $taxonomy => $ids) {
428 $taxQuery[] = [
429 'taxonomy' => $taxonomy,
430 'field' => 'term_id',
431 'terms' => $ids,
432 'operator' => 'IN',
433 ];
434 }
435
436 return $taxQuery;
437 }
438
439 private static function parseOrderBy(string $orderBy): array
440 {
441 // Parse combined value like "date_desc", "title_asc", "price_desc", "rand"
442 $allowedFields = ['date', 'title', 'price', 'rand'];
443 $allowedOrders = ['asc', 'desc'];
444
445 $field = 'title';
446 $dir = 'ASC';
447
448 if ($orderBy === 'rand') {
449 return ['rand', 'ASC'];
450 }
451
452 if (strpos($orderBy, '_') !== false) {
453 [$f, $d] = explode('_', $orderBy, 2);
454
455 if (in_array($f, $allowedFields, true)) {
456 $field = $f;
457 }
458
459 if (in_array($d, $allowedOrders, true)) {
460 $dir = strtoupper($d);
461 }
462 }
463
464 return [$field, $dir];
465 }
466
467 public static function create($data, $params = [])
468 {
469 }
470
471 public static function update($productDetail, $postId, $params = [])
472 {
473
474 }
475
476 public static function delete($detailId, $params = [])
477 {
478
479 }
480 }
481