|
|
@@ -0,0 +1,288 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+namespace Webkul\BagistoApi\Resolver;
|
|
|
+
|
|
|
+use ApiPlatform\GraphQl\Resolver\QueryItemResolverInterface;
|
|
|
+use ApiPlatform\Metadata\GetCollection;
|
|
|
+use ApiPlatform\State\Pagination\PaginatorInterface;
|
|
|
+use Illuminate\Support\Facades\DB;
|
|
|
+use Webkul\BagistoApi\Dto\CategoryProducts\AppliedFilterDto;
|
|
|
+use Webkul\BagistoApi\Dto\CategoryProducts\CategoryFilterEdgeDto;
|
|
|
+use Webkul\BagistoApi\Dto\ProductSearch\ProductSearchEdgeDto;
|
|
|
+use Webkul\BagistoApi\Models\CategoryProducts;
|
|
|
+use Webkul\BagistoApi\State\FilterableAttributesProvider;
|
|
|
+use Webkul\BagistoApi\State\ProductGraphQLProvider;
|
|
|
+
|
|
|
+class CategoryProductsResolver implements QueryItemResolverInterface
|
|
|
+{
|
|
|
+ public function __construct(
|
|
|
+ private readonly ProductGraphQLProvider $productProvider,
|
|
|
+ private readonly FilterableAttributesProvider $filterProvider,
|
|
|
+ ) {}
|
|
|
+
|
|
|
+ public function __invoke(?object $item, array $context): object
|
|
|
+ {
|
|
|
+ $args = $context['args'] ?? [];
|
|
|
+
|
|
|
+ $categorySlug = $args['categorySlug'] ?? null;
|
|
|
+
|
|
|
+ // When a collection field (appliedFilters / availableFilters) is empty,
|
|
|
+ // API Platform's ResolverFactory treats the empty array as "not yet
|
|
|
+ // fetched" and re-invokes the item resolver for that sub-field. Guard
|
|
|
+ // against this by returning an empty result for non-root field names.
|
|
|
+ $fieldName = $context['info']->fieldName ?? 'categoryProducts';
|
|
|
+ if (! $categorySlug || $fieldName !== 'categoryProducts') {
|
|
|
+ return new CategoryProducts;
|
|
|
+ }
|
|
|
+
|
|
|
+ $categoryId = DB::table('category_translations')
|
|
|
+ ->where('slug', $categorySlug)
|
|
|
+ ->value('category_id');
|
|
|
+
|
|
|
+ if (! $categoryId) {
|
|
|
+ throw new \RuntimeException("Category not found for slug: {$categorySlug}");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 1. Products — reuse the product provider with category + filters and
|
|
|
+ // default price-ascending order.
|
|
|
+ $filters = $this->parseFilter($args['filter'] ?? null);
|
|
|
+ $filters['category_id'] = (int) $categoryId;
|
|
|
+
|
|
|
+ $productArgs = $args;
|
|
|
+ $productArgs['filter'] = json_encode($filters);
|
|
|
+ // Default sort: cheapest first (sortKey PRICE, ascending).
|
|
|
+ if (! isset($productArgs['sortKey'])) {
|
|
|
+ $productArgs['sortKey'] = 'PRICE';
|
|
|
+ $productArgs['reverse'] = false;
|
|
|
+ }
|
|
|
+
|
|
|
+ $paginator = $this->productProvider->provide(
|
|
|
+ $context['operation'],
|
|
|
+ [],
|
|
|
+ ['args' => $productArgs]
|
|
|
+ );
|
|
|
+
|
|
|
+ if (! $paginator instanceof PaginatorInterface) {
|
|
|
+ throw new \UnexpectedValueException('Product provider must return a paginator.');
|
|
|
+ }
|
|
|
+
|
|
|
+ $result = new CategoryProducts;
|
|
|
+ $result->total_count = (int) $paginator->getTotalItems();
|
|
|
+
|
|
|
+ $offset = $this->resolveOffset($args, $result->total_count);
|
|
|
+
|
|
|
+ foreach ($paginator as $index => $product) {
|
|
|
+ $edge = new ProductSearchEdgeDto;
|
|
|
+ $edge->cursor = base64_encode((string) ($offset + $index));
|
|
|
+ $edge->node = $product;
|
|
|
+ $result->products[] = $edge;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. Available filters — reuse the filter provider (returns attributes
|
|
|
+ // with option-level product counts for the category). The client's
|
|
|
+ // selected filters are passed through so facet counts reflect the
|
|
|
+ // remaining result set (excluding each attribute's own selection).
|
|
|
+ $filterArgs = [
|
|
|
+ 'categorySlug' => $categorySlug,
|
|
|
+ 'filter' => $args['filter'] ?? null,
|
|
|
+ ];
|
|
|
+
|
|
|
+ $filterPaginator = $this->filterProvider->provide(
|
|
|
+ new GetCollection,
|
|
|
+ [],
|
|
|
+ ['source' => null, 'args' => $filterArgs, 'info' => null]
|
|
|
+ );
|
|
|
+
|
|
|
+ foreach ($filterPaginator as $index => $attribute) {
|
|
|
+ $edge = new CategoryFilterEdgeDto;
|
|
|
+ $edge->cursor = base64_encode((string) $index);
|
|
|
+ $edge->node = $attribute;
|
|
|
+ $result->available_filters[] = $edge;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. Applied filters — resolve the client's current selection into a
|
|
|
+ // structured list (code + label + option ids + option labels).
|
|
|
+ $result->applied_filters = $this->buildAppliedFilters($filters);
|
|
|
+
|
|
|
+ return $result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Resolve the current page offset from Relay cursor args.
|
|
|
+ */
|
|
|
+ private function resolveOffset(array $args, int $total): int
|
|
|
+ {
|
|
|
+ $limit = max(1, (int) ($args['first'] ?? $args['last'] ?? 30));
|
|
|
+ $offset = 0;
|
|
|
+
|
|
|
+ if (! empty($args['after'])) {
|
|
|
+ $decoded = base64_decode($args['after'], true);
|
|
|
+ $offset = ctype_digit((string) $decoded) ? ((int) $decoded + 1) : 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (! empty($args['before'])) {
|
|
|
+ $decoded = base64_decode($args['before'], true);
|
|
|
+ $cursor = ctype_digit((string) $decoded) ? (int) $decoded : 0;
|
|
|
+ $offset = max(0, $cursor - $limit);
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($offset > $total) {
|
|
|
+ $offset = max(0, $total - $limit);
|
|
|
+ }
|
|
|
+
|
|
|
+ return $offset;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Parse the `filter` arg (JSON string or already-decoded array) into an
|
|
|
+ * associative array of filters keyed by attribute code.
|
|
|
+ */
|
|
|
+ private function parseFilter(mixed $filter): array
|
|
|
+ {
|
|
|
+ if (empty($filter)) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
+ if (is_string($filter)) {
|
|
|
+ $decoded = json_decode($filter, true);
|
|
|
+
|
|
|
+ return is_array($decoded) ? $decoded : [];
|
|
|
+ }
|
|
|
+
|
|
|
+ return is_array($filter) ? $filter : [];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Build the structured "applied filters" list from the raw filter map.
|
|
|
+ *
|
|
|
+ * @param array<string, mixed> $filters
|
|
|
+ * @return list<AppliedFilterDto>
|
|
|
+ */
|
|
|
+ private function buildAppliedFilters(array $filters): array
|
|
|
+ {
|
|
|
+ if (empty($filters)) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+
|
|
|
+ // Non-attribute keys handled specially (price range) or ignored
|
|
|
+ // (already folded into the product query / pagination).
|
|
|
+ $ignored = ['category_id', 'type', 'sku', 'new', 'featured', 'pageSize', 'first', 'last', 'after', 'before'];
|
|
|
+
|
|
|
+ $price = [];
|
|
|
+ if (isset($filters['price_from'])) {
|
|
|
+ $price['from'] = (float) $filters['price_from'];
|
|
|
+ unset($filters['price_from']);
|
|
|
+ }
|
|
|
+ if (isset($filters['price_to'])) {
|
|
|
+ $price['to'] = (float) $filters['price_to'];
|
|
|
+ unset($filters['price_to']);
|
|
|
+ }
|
|
|
+
|
|
|
+ $applied = [];
|
|
|
+
|
|
|
+ foreach ($filters as $code => $spec) {
|
|
|
+ if (in_array($code, $ignored, true)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ $values = $this->extractValues($spec);
|
|
|
+ if (empty($values)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ $applied[] = $this->makeFilterDto($code, $values);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (! empty($price)) {
|
|
|
+ $dto = new AppliedFilterDto;
|
|
|
+ $dto->code = 'price';
|
|
|
+
|
|
|
+ $priceAttribute = DB::table('attributes')->where('code', 'price')->first();
|
|
|
+ $dto->label = $priceAttribute?->admin_name ?? 'Price';
|
|
|
+
|
|
|
+ $dto->values = [];
|
|
|
+ $dto->value_labels = [];
|
|
|
+
|
|
|
+ if (isset($price['from'])) {
|
|
|
+ $dto->values[] = (string) $price['from'];
|
|
|
+ $dto->value_labels[] = (string) $price['from'];
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isset($price['to'])) {
|
|
|
+ $dto->values[] = (string) $price['to'];
|
|
|
+ $dto->value_labels[] = (string) $price['to'];
|
|
|
+ }
|
|
|
+
|
|
|
+ $applied[] = $dto;
|
|
|
+ }
|
|
|
+
|
|
|
+ return $applied;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Extract the selected option id(s) from a filter spec. Accepts:
|
|
|
+ * "2", 2, ["2","3"], {"match": "2"}, {"match": "2,3"}
|
|
|
+ *
|
|
|
+ * @return list<string>
|
|
|
+ */
|
|
|
+ private function extractValues(mixed $spec): array
|
|
|
+ {
|
|
|
+ $raw = $spec;
|
|
|
+
|
|
|
+ if (is_array($spec)) {
|
|
|
+ if (isset($spec['match'])) {
|
|
|
+ $raw = $spec['match'];
|
|
|
+ } elseif (array_is_list($spec)) {
|
|
|
+ $raw = $spec;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (is_array($raw)) {
|
|
|
+ $values = [];
|
|
|
+ foreach ($raw as $v) {
|
|
|
+ foreach (explode(',', (string) $v) as $part) {
|
|
|
+ $part = trim($part);
|
|
|
+ if ($part !== '') {
|
|
|
+ $values[] = $part;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return array_values(array_unique($values));
|
|
|
+ }
|
|
|
+
|
|
|
+ $values = array_filter(array_map('trim', explode(',', (string) $raw)));
|
|
|
+
|
|
|
+ return array_values(array_unique($values));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Build an AppliedFilterDto with the attribute label and option labels
|
|
|
+ * resolved from the database.
|
|
|
+ *
|
|
|
+ * @param list<string> $values
|
|
|
+ */
|
|
|
+ private function makeFilterDto(string $code, array $values): AppliedFilterDto
|
|
|
+ {
|
|
|
+ $dto = new AppliedFilterDto;
|
|
|
+ $dto->code = $code;
|
|
|
+ $dto->values = $values;
|
|
|
+
|
|
|
+ $attribute = DB::table('attributes')->where('code', $code)->first();
|
|
|
+ if ($attribute) {
|
|
|
+ $dto->label = $attribute->admin_name;
|
|
|
+
|
|
|
+ $optionLabels = DB::table('attribute_options')
|
|
|
+ ->where('attribute_id', $attribute->id)
|
|
|
+ ->whereIn('id', $values)
|
|
|
+ ->pluck('admin_name', 'id');
|
|
|
+
|
|
|
+ $dto->value_labels = [];
|
|
|
+ foreach ($values as $value) {
|
|
|
+ $dto->value_labels[] = $optionLabels[$value] ?? $value;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return $dto;
|
|
|
+ }
|
|
|
+}
|