@scayle/storefront-product-listing 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ import { CentAmount, BreadcrumbItem } from '@scayle/storefront-nuxt';
2
+ import { Factory } from 'fishery';
3
+
4
+ interface AttributesFilterValue {
5
+ name: string;
6
+ id: number;
7
+ productCount: number;
8
+ value: string;
9
+ }
10
+ type BooleanFilterValue = [
11
+ {
12
+ name: true | false;
13
+ productCount: number;
14
+ },
15
+ {
16
+ name: true | false;
17
+ productCount: number;
18
+ }
19
+ ];
20
+ type RangeFilterValue = [
21
+ {
22
+ min: CentAmount;
23
+ max: CentAmount;
24
+ productCount: number;
25
+ }
26
+ ];
27
+ type FilterItemWithValues = BooleanFilterItemWithValues | AttributesFilterItemWithValues | RangeFilterItemWithValues;
28
+ interface BooleanFilterItemWithValues {
29
+ id: null;
30
+ slug: string;
31
+ name: string;
32
+ values: BooleanFilterValue;
33
+ type: 'boolean';
34
+ }
35
+ interface AttributesFilterItemWithValues {
36
+ id: number | null;
37
+ slug: string;
38
+ name: string;
39
+ values: AttributesFilterValue[];
40
+ type: 'attributes';
41
+ attributeGroupType: string;
42
+ }
43
+ interface RangeFilterItemWithValues {
44
+ id: null;
45
+ slug: string;
46
+ name: string;
47
+ values: RangeFilterValue;
48
+ type: 'range';
49
+ }
50
+ type RangeTuple = [start: number, end: number];
51
+
52
+ declare const breadcrumbsFactory: Factory<BreadcrumbItem[], any, BreadcrumbItem[]>;
53
+
54
+ declare const filtersFactory: Factory<FilterItemWithValues[], any, FilterItemWithValues[]>;
55
+
56
+ export { type AttributesFilterItemWithValues, type AttributesFilterValue, type BooleanFilterItemWithValues, type BooleanFilterValue, type FilterItemWithValues, type RangeFilterItemWithValues, type RangeFilterValue, type RangeTuple, breadcrumbsFactory, filtersFactory };
@@ -0,0 +1,56 @@
1
+ import { CentAmount, BreadcrumbItem } from '@scayle/storefront-nuxt';
2
+ import { Factory } from 'fishery';
3
+
4
+ interface AttributesFilterValue {
5
+ name: string;
6
+ id: number;
7
+ productCount: number;
8
+ value: string;
9
+ }
10
+ type BooleanFilterValue = [
11
+ {
12
+ name: true | false;
13
+ productCount: number;
14
+ },
15
+ {
16
+ name: true | false;
17
+ productCount: number;
18
+ }
19
+ ];
20
+ type RangeFilterValue = [
21
+ {
22
+ min: CentAmount;
23
+ max: CentAmount;
24
+ productCount: number;
25
+ }
26
+ ];
27
+ type FilterItemWithValues = BooleanFilterItemWithValues | AttributesFilterItemWithValues | RangeFilterItemWithValues;
28
+ interface BooleanFilterItemWithValues {
29
+ id: null;
30
+ slug: string;
31
+ name: string;
32
+ values: BooleanFilterValue;
33
+ type: 'boolean';
34
+ }
35
+ interface AttributesFilterItemWithValues {
36
+ id: number | null;
37
+ slug: string;
38
+ name: string;
39
+ values: AttributesFilterValue[];
40
+ type: 'attributes';
41
+ attributeGroupType: string;
42
+ }
43
+ interface RangeFilterItemWithValues {
44
+ id: null;
45
+ slug: string;
46
+ name: string;
47
+ values: RangeFilterValue;
48
+ type: 'range';
49
+ }
50
+ type RangeTuple = [start: number, end: number];
51
+
52
+ declare const breadcrumbsFactory: Factory<BreadcrumbItem[], any, BreadcrumbItem[]>;
53
+
54
+ declare const filtersFactory: Factory<FilterItemWithValues[], any, FilterItemWithValues[]>;
55
+
56
+ export { type AttributesFilterItemWithValues, type AttributesFilterValue, type BooleanFilterItemWithValues, type BooleanFilterValue, type FilterItemWithValues, type RangeFilterItemWithValues, type RangeFilterValue, type RangeTuple, breadcrumbsFactory, filtersFactory };
package/dist/index.mjs ADDED
@@ -0,0 +1,53 @@
1
+ import { Factory } from 'fishery';
2
+
3
+ const breadcrumbsFactory = Factory.define(() => [
4
+ { to: "/de/c/women/clothing/shirts-2045", value: "shirts" },
5
+ { to: "/de/c/women/clothing-2048", value: "clothing" }
6
+ ]);
7
+
8
+ const filtersFactory = Factory.define(
9
+ () => [{
10
+ id: 1,
11
+ slug: "brand",
12
+ name: "Brand",
13
+ values: [
14
+ {
15
+ name: "value",
16
+ id: 2,
17
+ productCount: 10,
18
+ value: "123"
19
+ }
20
+ ],
21
+ type: "attributes",
22
+ attributeGroupType: "group-type"
23
+ }, {
24
+ id: null,
25
+ slug: "sale",
26
+ name: "Sale",
27
+ values: [
28
+ {
29
+ name: true,
30
+ productCount: 12
31
+ },
32
+ {
33
+ name: false,
34
+ productCount: 2
35
+ }
36
+ ],
37
+ type: "boolean"
38
+ }, {
39
+ id: null,
40
+ slug: "prices",
41
+ name: "Prices",
42
+ values: [
43
+ {
44
+ min: 10,
45
+ max: 300,
46
+ productCount: 30
47
+ }
48
+ ],
49
+ type: "range"
50
+ }]
51
+ );
52
+
53
+ export { breadcrumbsFactory, filtersFactory };
package/dist/module.mjs CHANGED
@@ -13,14 +13,11 @@ const module = defineNuxtModule({
13
13
  defaults: {},
14
14
  setup(options, nuxt) {
15
15
  const { resolve } = createResolver(import.meta.url);
16
- nuxt.options.alias["#storefront-product-listing/composables"] = resolve(
17
- "./runtime/composables"
18
- );
19
- nuxt.options.build.transpile.push(
20
- "#storefront-product-listing/composables"
21
- );
16
+ nuxt.options.alias["#storefront-product-listing"] = resolve("./runtime");
17
+ nuxt.options.build.transpile.push("#storefront-product-listing/runtime");
22
18
  if (options.autoImports) {
23
19
  addImportsDir(resolve("./runtime/composables"));
20
+ addImportsDir(resolve("./runtime/utils"));
24
21
  }
25
22
  }
26
23
  });
@@ -0,0 +1,37 @@
1
+ import type { ProductSearchQuery } from '@scayle/storefront-nuxt';
2
+ export interface AppliedFiltersOptions {
3
+ filtersPrefix: string;
4
+ }
5
+ /**
6
+ * Composable to manage applied filters based on route query parameters.
7
+ *
8
+ * @param {object} options - Configuration options for applied filters.
9
+ * @param {string} options.filtersPrefix - The prefix used to identify filter-related query parameters.
10
+ *
11
+ * @returns {object} - An object containing the following properties:
12
+ *
13
+ * - `appliedFilter` {ComputedRef<ProductSearchQuery>}: A computed property returning a `ProductSearchQuery` object based on the route's query parameters.
14
+ * - `attributes`: An array of attribute filters, where each filter has:
15
+ * - `type`: A string, either 'boolean' or 'attributes'.
16
+ * - `key`: The name of the attribute.
17
+ * - `value`: A boolean for boolean filters.
18
+ * - `values`: An array of numbers for attribute filters.
19
+ * - `minPrice`: (Optional) The minimum price filter as a number.
20
+ * - `maxPrice`: (Optional) The maximum price filter as a number.
21
+ * - `term`: (Optional) A search term filter as a string.
22
+ *
23
+ * - `appliedFiltersCount` {ComputedRef<number>}: A computed property that returns the total count of applied filters. Counts minPrice/maxPrice as one and counts each attribute filter separately.
24
+ *
25
+ * - `appliedAttributeValues` {ComputedRef<Record<string, Array<number>>>}: A computed property that returns a record mapping attribute keys to arrays of numbers representing selected values for each attribute.
26
+ *
27
+ * - `appliedBooleanValues` {ComputedRef<Record<string, boolean>>}: A computed property that returns a record mapping attribute keys to their boolean values for boolean filters.
28
+ *
29
+ * - `areFiltersApplied` {ComputedRef<boolean>}: A computed property that returns `true` if any filters are currently applied, otherwise `false`.
30
+ */
31
+ export declare function useAppliedFilters({ filtersPrefix }?: AppliedFiltersOptions): {
32
+ appliedFilter: import("vue").ComputedRef<ProductSearchQuery>;
33
+ appliedFiltersCount: import("vue").ComputedRef<number>;
34
+ appliedAttributeValues: import("vue").ComputedRef<Record<string, (string | number)[]>>;
35
+ appliedBooleanValues: import("vue").ComputedRef<Record<string, boolean>>;
36
+ areFiltersApplied: import("vue").ComputedRef<boolean>;
37
+ };
@@ -0,0 +1,46 @@
1
+ import { computed } from "vue";
2
+ import { useRoute } from "#app/composables/router";
3
+ import { parseFilterDataFromRoute } from "../utils/filters.js";
4
+ export function useAppliedFilters({ filtersPrefix } = { filtersPrefix: "filters" }) {
5
+ const route = useRoute();
6
+ const appliedFilter = computed(() => {
7
+ return parseFilterDataFromRoute(route, filtersPrefix);
8
+ });
9
+ const appliedFiltersCount = computed(() => {
10
+ let count = 0;
11
+ count += appliedFilter.value.attributes?.length ?? 0;
12
+ if (appliedFilter.value.minPrice !== void 0 || appliedFilter.value.maxPrice !== void 0) {
13
+ count += 1;
14
+ }
15
+ return count;
16
+ });
17
+ const appliedAttributeValues = computed(() => {
18
+ const values = {};
19
+ appliedFilter.value?.attributes?.forEach((attribute) => {
20
+ if (attribute.type !== "attributes" || !attribute.key) {
21
+ return;
22
+ }
23
+ values[attribute.key] = attribute.values;
24
+ });
25
+ return values;
26
+ });
27
+ const appliedBooleanValues = computed(() => {
28
+ const values = {};
29
+ appliedFilter.value?.attributes?.forEach((attribute) => {
30
+ if (attribute.type === "boolean") {
31
+ values[attribute.key] = attribute.value;
32
+ }
33
+ });
34
+ return values;
35
+ });
36
+ const areFiltersApplied = computed(() => {
37
+ return appliedFiltersCount.value > 0;
38
+ });
39
+ return {
40
+ appliedFilter,
41
+ appliedFiltersCount,
42
+ appliedAttributeValues,
43
+ appliedBooleanValues,
44
+ areFiltersApplied
45
+ };
46
+ }
@@ -0,0 +1,32 @@
1
+ import { useFilters } from '@scayle/storefront-nuxt/composables';
2
+ import { type ComputedRef, type MaybeRefOrGetter } from 'vue';
3
+ import type { LocationQuery } from 'vue-router';
4
+ import type { FilterItemWithValues } from '../../types/filter.js';
5
+ export type ProductListFilterOptions = Partial<{
6
+ immediate: boolean;
7
+ keyPrefix: string;
8
+ }>;
9
+ type UseProductListFilterReturn = Pick<Awaited<ReturnType<typeof useFilters>>, 'error' | 'status' | 'fetch' | 'fetching'> & {
10
+ availableFilters: ComputedRef<FilterItemWithValues[]>;
11
+ clearedPriceQuery: ComputedRef<LocationQuery | undefined>;
12
+ unfilteredCount: ComputedRef<number>;
13
+ };
14
+ /**
15
+ * Composable to manage filters based on current category ID and route query parameters.
16
+ *
17
+ * @param {number} [currentCategoryId] - current/active category ID
18
+ * @param {ProductListingFilterOptions} options - Configuration options for filters.
19
+ * @param {string} options.immediate - Will enable "immediate" flag via "useFilters" composable in it.
20
+ * @param {string} options.keyPrefix - The prefix used to construct "key" via "useFilters" payload. Default is "base".
21
+ *
22
+ * @returns {UseProductListFilterReturn} - An object containing the following properties:
23
+ * - `fetch`: call for fetching the filters.
24
+ * - `error`: returns "useFilters" error.
25
+ * - `status`: returns "useFilters" status.
26
+ * - `fetching`: returns "useFilters" fetching flag.
27
+ * - `availableFilters` {ComputedRef<FilterItemWithValues[]>}: returns computed available filters.
28
+ * - `unfilteredCount` {ComputedRef<number>}: returns unfiltered filters count.
29
+ * - `clearedPriceQuery` {ComputedRef<LocationQuery | undefined>}: constructs an empty query object.
30
+ */
31
+ export declare function useProductListFilter(currentCategoryId?: MaybeRefOrGetter<number | undefined>, options?: ProductListFilterOptions): UseProductListFilterReturn & Promise<UseProductListFilterReturn>;
32
+ export {};
@@ -0,0 +1,60 @@
1
+ import { extendPromise } from "@scayle/storefront-nuxt";
2
+ import { useFilters } from "@scayle/storefront-nuxt/composables";
3
+ import { computed, toValue } from "vue";
4
+ import { getClearedPriceQuery } from "../utils/filters.js";
5
+ import { useRoute } from "#app/composables/router";
6
+ import { useAppliedFilters } from "./useAppliedFilters.js";
7
+ export function useProductListFilter(currentCategoryId, options = {}) {
8
+ const route = useRoute();
9
+ const { immediate = true, keyPrefix = "base" } = options;
10
+ const { appliedFilter } = useAppliedFilters();
11
+ const filterData = useFilters({
12
+ params: computed(() => ({
13
+ categoryId: toValue(currentCategoryId),
14
+ where: {
15
+ ...appliedFilter.value,
16
+ ...route.query.term && { term: String(route.query.term) }
17
+ },
18
+ includeSellableForFree: true,
19
+ includeSoldOut: false
20
+ })),
21
+ options: { immediate },
22
+ key: `${toValue(currentCategoryId) ?? keyPrefix}-filters`
23
+ });
24
+ const {
25
+ data,
26
+ fetch,
27
+ fetching,
28
+ status,
29
+ error
30
+ } = filterData;
31
+ const availableFilters = computed(() => {
32
+ return data.value?.filters.filter((filter) => {
33
+ if (filter.type !== "boolean") {
34
+ return true;
35
+ }
36
+ const trueValue = filter.values.find(
37
+ (value) => "name" in value && typeof value.name === "boolean" && value.name
38
+ );
39
+ if (!trueValue) {
40
+ return false;
41
+ }
42
+ return trueValue.productCount !== 0;
43
+ });
44
+ });
45
+ const unfilteredCount = computed(() => {
46
+ return data.value?.unfilteredCount ?? 0;
47
+ });
48
+ const clearedPriceQuery = computed(() => {
49
+ return getClearedPriceQuery(route);
50
+ });
51
+ return extendPromise(filterData.then(() => ({})), {
52
+ fetch,
53
+ status,
54
+ fetching,
55
+ error,
56
+ availableFilters,
57
+ unfilteredCount,
58
+ clearedPriceQuery
59
+ });
60
+ }
@@ -0,0 +1,39 @@
1
+ import { APISortOption, APISortOrder, type ProductSortConfig } from '@scayle/storefront-nuxt';
2
+ type SelectedSort = ProductSortConfig & {
3
+ key: string;
4
+ label: string;
5
+ };
6
+ export interface ProductListSortOptions {
7
+ sortingOptions?: SelectedSort[];
8
+ defaultSortingKey?: string;
9
+ }
10
+ /**
11
+ * Custom composable to handle product list sorting.
12
+ *
13
+ * @param {ProductListOptions} [options] - Options for configuring the sorting behavior.
14
+ * @param {SelectedSort[]} [options.sortingOptions] - Array of available sorting options.
15
+ * @param {string} [options.defaultSortingKey] - Default sorting key to be applied if no specific sort is selected.
16
+ * @returns - computed properties and methods related to sorting:
17
+ * - {ComputedRef<SelectedSort | undefined>} selectedSort - The currently selected sorting option.
18
+ * - {ComputedRef<SelectedSort[]>} sortLinks - Links with updated query parameters for each sorting option.
19
+ * - {ComputedRef<boolean>} isDefaultSortSelected - Boolean indicating if the default sort is selected.
20
+ */
21
+ export declare function useProductListSort({ sortingOptions, defaultSortingKey }?: ProductListSortOptions): {
22
+ selectedSort: import("vue").ComputedRef<SelectedSort | undefined>;
23
+ sortLinks: import("vue").ComputedRef<{
24
+ to: {
25
+ query: {
26
+ sort: string;
27
+ };
28
+ };
29
+ by?: APISortOption;
30
+ direction?: APISortOrder;
31
+ score?: "category_scores" | "brand_scores";
32
+ channel?: string;
33
+ sortingKey?: string | string[];
34
+ key: string;
35
+ label: string;
36
+ }[]>;
37
+ isDefaultSortSelected: import("vue").ComputedRef<boolean>;
38
+ };
39
+ export {};
@@ -0,0 +1,67 @@
1
+ import {
2
+ APISortOption,
3
+ APISortOrder
4
+ } from "@scayle/storefront-nuxt";
5
+ import { computed } from "vue";
6
+ import { useRoute } from "#app/composables/router";
7
+ export function useProductListSort({ sortingOptions, defaultSortingKey } = {
8
+ sortingOptions: [
9
+ {
10
+ key: "top_seller",
11
+ sortingKey: "scayle:v1:recommended",
12
+ direction: APISortOrder.Descending,
13
+ label: "sorting_select.top_seller"
14
+ },
15
+ {
16
+ key: "date_newest",
17
+ by: APISortOption.DateAdded,
18
+ direction: APISortOrder.Descending,
19
+ label: "sorting_select.date_newest"
20
+ },
21
+ {
22
+ key: "price_desc",
23
+ by: APISortOption.Price,
24
+ direction: APISortOrder.Descending,
25
+ label: "sorting_select.price_desc"
26
+ },
27
+ {
28
+ key: "price_asc",
29
+ by: APISortOption.Price,
30
+ direction: APISortOrder.Ascending,
31
+ label: "sorting_select.price_asc"
32
+ },
33
+ {
34
+ key: "reduction_desc",
35
+ by: APISortOption.Reduction,
36
+ direction: APISortOrder.Descending,
37
+ label: "sorting_select.reduction_desc"
38
+ }
39
+ ],
40
+ defaultSortingKey: "top_seller"
41
+ }) {
42
+ const route = useRoute();
43
+ const selectedSort = computed(() => {
44
+ const sort = route.query.sort && sortingOptions?.find((option) => option.key === route.query.sort);
45
+ if (!sort) {
46
+ return sortingOptions?.find((option) => option.key === defaultSortingKey);
47
+ }
48
+ return sort;
49
+ });
50
+ const sortLinks = computed(() => {
51
+ const { page, ...query } = route.query;
52
+ return sortingOptions?.map((option) => ({
53
+ ...option,
54
+ to: {
55
+ query: { ...query, sort: option.key }
56
+ }
57
+ })) || [];
58
+ });
59
+ const isDefaultSortSelected = computed(() => {
60
+ return selectedSort.value?.key === defaultSortingKey;
61
+ });
62
+ return {
63
+ selectedSort,
64
+ sortLinks,
65
+ isDefaultSortSelected
66
+ };
67
+ }
@@ -0,0 +1,29 @@
1
+ import { type ComputedRef, type Ref } from 'vue';
2
+ import type { BreadcrumbItem, Category } from '@scayle/storefront-nuxt';
3
+ import type { BreadcrumbList, WithContext } from 'schema-dts';
4
+ type CanonicalLink = Record<'rel' | 'key' | 'href', string>;
5
+ type UrlParams = Record<'baseUrl' | 'fullPath', string>;
6
+ interface UseProductListingSeoData {
7
+ title: ComputedRef<string>;
8
+ activeCategoryName: ComputedRef<string | undefined>;
9
+ robots: ComputedRef<string>;
10
+ canonicalLink: ComputedRef<CanonicalLink[]>;
11
+ categoryBreadcrumbSchema: ComputedRef<WithContext<BreadcrumbList> | []>;
12
+ }
13
+ /**
14
+ * Composable for handling product listing SEO data.
15
+ *
16
+ * @param {Ref<Category>} category - Reactive category
17
+ * @param {BreadcrumbItem[]} breadcrumbs - Breadcrumb items.
18
+ * @param {UrlParams} URL params - URL data for constructing the canonical link.
19
+ *
20
+ * @returns {UseProductListingSeoData} An object containing reactive data and computed properties for managing SEO data.
21
+ *
22
+ * @property {ComputedRef<string>} title - A computed string that is constructed from breadcrumb values.
23
+ * @property {ComputedRef<string | undefined>} activeCategoryName - A computed active category name.
24
+ * @property {ComputedRef<string>} robots - A computed robots. Value can be "index,follow" or "noindex,follow".
25
+ * @property {ComputedRef<CanonicalLink[]>} canonicalLink - A computed canonical link.
26
+ * @property {ComputedRef<WithContext<BreadcrumbList> | []>} categoryBreadcrumbSchema - A computed breadcrumb schema.
27
+ */
28
+ export declare function useProductListingSeoData(category: Ref<Category | undefined | null>, breadcrumbs: BreadcrumbItem[], { baseUrl, fullPath }: UrlParams): UseProductListingSeoData;
29
+ export {};
@@ -0,0 +1,39 @@
1
+ import { computed } from "vue";
2
+ import {
3
+ generateCategoryBreadcrumbSchema,
4
+ sanitizeCanonicalURL
5
+ } from "@scayle/storefront-nuxt";
6
+ import { useAppliedFilters } from "./useAppliedFilters.js";
7
+ import { useProductListSort } from "./useProductListSort.js";
8
+ export function useProductListingSeoData(category, breadcrumbs, { baseUrl, fullPath }) {
9
+ const { areFiltersApplied } = useAppliedFilters();
10
+ const { isDefaultSortSelected } = useProductListSort();
11
+ const robots = computed(() => {
12
+ return !areFiltersApplied.value && isDefaultSortSelected.value ? "index,follow" : "noindex,follow";
13
+ });
14
+ const canonicalLink = computed(
15
+ () => robots.value.includes("noindex") ? [] : [{
16
+ rel: "canonical",
17
+ key: "canonical",
18
+ href: sanitizeCanonicalURL(`${baseUrl}${fullPath}`)
19
+ }]
20
+ );
21
+ const activeCategoryName = computed(
22
+ () => category.value?.name?.toLowerCase()
23
+ );
24
+ const title = computed(() => {
25
+ return category.value ? breadcrumbs.map(({ value }) => value).join(" - ") : "";
26
+ });
27
+ const categoryBreadcrumbSchema = computed(
28
+ () => {
29
+ return category.value ? generateCategoryBreadcrumbSchema(breadcrumbs) : [];
30
+ }
31
+ );
32
+ return {
33
+ title,
34
+ activeCategoryName,
35
+ robots,
36
+ canonicalLink,
37
+ categoryBreadcrumbSchema
38
+ };
39
+ }
@@ -0,0 +1,47 @@
1
+ import { type FetchProductsByCategoryParams, type Product, type ProductWith } from '@scayle/storefront-nuxt';
2
+ import type { LocationQuery } from 'vue-router';
3
+ import { type MaybeRefOrGetter, type ComputedRef } from 'vue';
4
+ import { useProducts } from '@scayle/storefront-nuxt/composables';
5
+ import type { Pagination } from '@scayle/storefront-api';
6
+ type UseProductsByCategoryReturn = Pick<Awaited<ReturnType<typeof useProducts>>, 'error' | 'fetching'> & {
7
+ products: ComputedRef<Product[]>;
8
+ pagination: ComputedRef<Pagination | undefined>;
9
+ totalProductsCount: ComputedRef<number>;
10
+ paginationOffset: ComputedRef<number>;
11
+ };
12
+ /**
13
+ * Composable to fetch and manage products by category with configurable options.
14
+ *
15
+ * @param {MaybeRefOrGetter<number>} categoryId - The ID of the category to fetch products for. Accepts a ref, computed, or static number.
16
+ * @param {ProductsByCategoryOptions} options - Configuration options for fetching products.
17
+ * @param {Partial<FetchProductsByCategoryParams>} options.params - Parameters to control product fetch behavior, excluding `category` and `categoryId`.
18
+ * @param {Partial<{ lazy: boolean; immediate: boolean }>} options.fetchingOptions - Options for fetch timing, such as lazy loading or immediate fetching.
19
+ * @param {number} options.productsPerPage - Number of products to fetch per page.
20
+ * @param {number} options.fetchProductsCacheTtl - Cache time-to-live for product data in milliseconds.
21
+ * @param {string} options.cacheKeyPrefix - Prefix for cache keys to uniquely identify cached data for this fetch.
22
+ * @param {string} [options.fetchingKey] - Optional unique key to control the fetching cache.
23
+ * @param {ProductWith} [options.withParams] - Additional fields to include with each product.
24
+ *
25
+ * @returns {UseProductsByCategoryReturn} - An object containing reactive data and computed properties for managing products by category.
26
+ * @property {ComputedRef<Product[]>} products - A computed array of products in the specified category.
27
+ * @property {ComputedRef<Pagination>} pagination - A computed object containing pagination details for the product list.
28
+ * @property {ComputedRef<number>} totalProductsCount - A computed total count of products in the category.
29
+ * @property {ComputedRef<number>} paginationOffset - A computed offset for the current page based on the pagination data.
30
+ * @property {Ref<boolean>} fetching - A reactive boolean indicating if products are currently being fetched.
31
+ * @property {Ref<Error | null>} error - A reactive error object, null if no error occurred.
32
+ */
33
+ export interface ProductsByCategoryOptions {
34
+ params: Omit<Partial<FetchProductsByCategoryParams>, 'category' | 'categoryId'>;
35
+ fetchingOptions: Partial<{
36
+ lazy: boolean;
37
+ immediate: boolean;
38
+ }>;
39
+ productsPerPage: number;
40
+ fetchProductsCacheTtl: number;
41
+ cacheKeyPrefix: string;
42
+ fetchingKey?: string;
43
+ withParams?: ProductWith;
44
+ query: LocationQuery;
45
+ }
46
+ export declare function useProductsByCategory(categoryId: MaybeRefOrGetter<number>, { params, withParams, cacheKeyPrefix, fetchingKey, fetchingOptions, fetchProductsCacheTtl, productsPerPage, query, }?: Partial<ProductsByCategoryOptions>): UseProductsByCategoryReturn & Promise<UseProductsByCategoryReturn>;
47
+ export {};
@@ -0,0 +1,75 @@
1
+ import {
2
+ extendPromise
3
+ } from "@scayle/storefront-nuxt";
4
+ import { computed, toValue } from "vue";
5
+ import { useProducts } from "@scayle/storefront-nuxt/composables";
6
+ import { useProductListSort } from "./useProductListSort.js";
7
+ import { useAppliedFilters } from "./useAppliedFilters.js";
8
+ const PRODUCTS_PER_PAGE = 24;
9
+ const DEFAULT_CACHE_TTL = 1e3;
10
+ const DEFAULT_CACHE_KEY_PREFIX = "PLP";
11
+ export function useProductsByCategory(categoryId, {
12
+ params,
13
+ withParams,
14
+ cacheKeyPrefix,
15
+ fetchingKey,
16
+ fetchingOptions,
17
+ fetchProductsCacheTtl,
18
+ productsPerPage,
19
+ query
20
+ } = {
21
+ params: {
22
+ includeSoldOut: false,
23
+ includeSellableForFree: false,
24
+ pricePromotionKey: ""
25
+ },
26
+ fetchingOptions: {},
27
+ productsPerPage: PRODUCTS_PER_PAGE,
28
+ fetchProductsCacheTtl: DEFAULT_CACHE_TTL,
29
+ cacheKeyPrefix: DEFAULT_CACHE_KEY_PREFIX
30
+ }) {
31
+ const { selectedSort } = useProductListSort();
32
+ const { appliedFilter } = useAppliedFilters();
33
+ const productsData = useProducts({
34
+ params: () => ({
35
+ ...query?.page && { page: +query.page },
36
+ sort: selectedSort.value,
37
+ perPage: query?.products_per_page ? parseInt(query.products_per_page, 10) : productsPerPage,
38
+ with: withParams,
39
+ categoryId: toValue(categoryId),
40
+ includeSoldOut: params?.includeSoldOut,
41
+ includeSellableForFree: params?.includeSellableForFree,
42
+ pricePromotionKey: params?.pricePromotionKey,
43
+ orFiltersOperator: params?.orFiltersOperator,
44
+ cache: {
45
+ ttl: fetchProductsCacheTtl,
46
+ cacheKeyPrefix: cacheKeyPrefix || `PLP:${toValue(categoryId)}`
47
+ },
48
+ where: appliedFilter.value,
49
+ ...params
50
+ }),
51
+ key: fetchingKey || `${toValue(categoryId)}-products`,
52
+ options: fetchingOptions
53
+ });
54
+ const products = computed(() => productsData.data.value?.products ?? []);
55
+ const pagination = computed(() => productsData.data.value?.pagination);
56
+ const totalProductsCount = computed(
57
+ () => productsData.data.value?.pagination.total ?? 0
58
+ );
59
+ const paginationOffset = computed(() => {
60
+ const page = pagination.value?.page ?? 1;
61
+ const perPage = pagination.value?.perPage ?? productsPerPage ?? PRODUCTS_PER_PAGE;
62
+ return (page - 1) * perPage;
63
+ });
64
+ return extendPromise(
65
+ productsData.then(() => ({})),
66
+ {
67
+ error: productsData.error,
68
+ fetching: productsData.fetching,
69
+ products,
70
+ pagination,
71
+ totalProductsCount,
72
+ paginationOffset
73
+ }
74
+ );
75
+ }
@@ -0,0 +1,6 @@
1
+ export * from './composables/useProductListSort.js';
2
+ export * from './composables/useAppliedFilters.js';
3
+ export * from './composables/useProductListFilter.js';
4
+ export * from './composables/useProductListingSeoData.js';
5
+ export * from './composables/useProductsByCategory.js';
6
+ export * from './utils/filters.js';
@@ -0,0 +1,6 @@
1
+ export * from "./composables/useProductListSort.js";
2
+ export * from "./composables/useAppliedFilters.js";
3
+ export * from "./composables/useProductListFilter.js";
4
+ export * from "./composables/useProductListingSeoData.js";
5
+ export * from "./composables/useProductsByCategory.js";
6
+ export * from "./utils/filters.js";
@@ -0,0 +1,16 @@
1
+ import type { LocationQuery, RouteLocationNormalizedLoadedGeneric } from 'vue-router';
2
+ import type { ProductSearchQuery } from '@scayle/storefront-api';
3
+ import type { RangeTuple } from '../../types/filter.js';
4
+ export declare const parseFilterDataFromRoute: (route: RouteLocationNormalizedLoadedGeneric, filtersPrefix: string) => ProductSearchQuery;
5
+ export declare const getClearedFilterQueryByKey: (route: RouteLocationNormalizedLoadedGeneric, key: string) => LocationQuery | undefined;
6
+ export declare const getNewQueryFilters: (route: RouteLocationNormalizedLoadedGeneric, filter: LocationQuery) => LocationQuery;
7
+ export declare const createNewAttributeQuery: (route: RouteLocationNormalizedLoadedGeneric, appliedFilter: ProductSearchQuery, { slug, id }: {
8
+ slug: string;
9
+ id: number;
10
+ }) => LocationQuery;
11
+ export declare const createNewPriceQuery: (route: RouteLocationNormalizedLoadedGeneric, appliedFilter: ProductSearchQuery, prices: RangeTuple) => LocationQuery;
12
+ export declare const createNewBoolAttributeQuery: (route: RouteLocationNormalizedLoadedGeneric, appliedFilter: ProductSearchQuery, { slug, value }: {
13
+ slug: string;
14
+ value: boolean;
15
+ }) => LocationQuery;
16
+ export declare const getClearedPriceQuery: (route: RouteLocationNormalizedLoadedGeneric) => LocationQuery | undefined;
@@ -0,0 +1,138 @@
1
+ const getAttributeValues = (value) => {
2
+ return value.split(",").map((v) => Number(v)).filter((v) => !isNaN(v));
3
+ };
4
+ export const parseFilterDataFromRoute = (route, filtersPrefix) => {
5
+ const queryKeys = Object.keys(route.query);
6
+ const productSearchQuery = { attributes: [] };
7
+ queryKeys.forEach((queryKey) => {
8
+ const value = route.query[queryKey];
9
+ if (!value) {
10
+ return;
11
+ }
12
+ if (!queryKey.includes(filtersPrefix)) {
13
+ return;
14
+ }
15
+ const filterName = queryKey.replace(filtersPrefix, "").replace("]", "").replace("[", "");
16
+ switch (filterName) {
17
+ case "minPrice":
18
+ case "maxPrice":
19
+ if (isNaN(Number(value))) {
20
+ return;
21
+ }
22
+ productSearchQuery[filterName] = Number(value);
23
+ break;
24
+ case "term":
25
+ productSearchQuery.term = value;
26
+ break;
27
+ default:
28
+ if (value === "true" || value === "false") {
29
+ const booleanFilter = {
30
+ type: "boolean",
31
+ key: filterName,
32
+ value: value === "true"
33
+ };
34
+ return productSearchQuery.attributes?.push(booleanFilter);
35
+ } else {
36
+ const values = getAttributeValues(value);
37
+ if (values.length === 0) {
38
+ return;
39
+ }
40
+ const attributeFilter = {
41
+ type: "attributes",
42
+ key: filterName,
43
+ values
44
+ };
45
+ return productSearchQuery.attributes?.push(attributeFilter);
46
+ }
47
+ }
48
+ });
49
+ return productSearchQuery;
50
+ };
51
+ export const getClearedFilterQueryByKey = (route, key) => {
52
+ const query = { ...route.query };
53
+ const filterKey = `filters[${key}]`;
54
+ if (!query[filterKey]) {
55
+ return;
56
+ }
57
+ delete query[filterKey];
58
+ return query;
59
+ };
60
+ export const getNewQueryFilters = (route, filter) => {
61
+ const newQuery = {
62
+ sort: route.query.sort,
63
+ term: route.query.term,
64
+ ...filter
65
+ };
66
+ if ("page" in newQuery) {
67
+ delete newQuery.page;
68
+ }
69
+ return newQuery;
70
+ };
71
+ export const createNewAttributeQuery = (route, appliedFilter, { slug, id }) => {
72
+ const attributeFilterQuery = {};
73
+ const query = { ...route.query };
74
+ const attribute = appliedFilter.attributes?.find(({ key: key2 }) => key2 === slug);
75
+ const key = `filters[${slug}]`;
76
+ if (!attribute) {
77
+ attributeFilterQuery[key] = `${id}`;
78
+ return { ...query, ...attributeFilterQuery };
79
+ }
80
+ if (attribute?.type !== "attributes") {
81
+ return query;
82
+ }
83
+ const values = attribute.values;
84
+ const index = values.indexOf(id);
85
+ if (index === -1) {
86
+ values.push(id);
87
+ } else {
88
+ values.splice(index, 1);
89
+ }
90
+ if (values.length === 0) {
91
+ delete query[key];
92
+ return query;
93
+ }
94
+ attributeFilterQuery[key] = values.join(",");
95
+ return { ...query, ...attributeFilterQuery };
96
+ };
97
+ export const createNewPriceQuery = (route, appliedFilter, prices) => {
98
+ const priceFilterQuery = {};
99
+ const query = { ...route.query };
100
+ if (appliedFilter.minPrice !== prices[0]) {
101
+ priceFilterQuery["filters[minPrice]"] = prices[0].toString();
102
+ }
103
+ if (appliedFilter.maxPrice !== prices[1]) {
104
+ priceFilterQuery["filters[maxPrice]"] = prices[1].toString();
105
+ }
106
+ return { ...query, ...priceFilterQuery };
107
+ };
108
+ export const createNewBoolAttributeQuery = (route, appliedFilter, { slug, value }) => {
109
+ const booleanFilterQuery = {};
110
+ const query = { ...route.query };
111
+ const attribute = appliedFilter.attributes?.find(({ key: key2 }) => key2 === slug);
112
+ const key = `filters[${slug}]`;
113
+ if (!attribute) {
114
+ if (!value) {
115
+ return query;
116
+ }
117
+ booleanFilterQuery[key] = String(value);
118
+ return { ...query, ...booleanFilterQuery };
119
+ }
120
+ if (attribute.type !== "boolean") {
121
+ return query;
122
+ }
123
+ if (!value) {
124
+ delete query[key];
125
+ return query;
126
+ }
127
+ booleanFilterQuery[key] = String(value);
128
+ return { ...query, ...booleanFilterQuery };
129
+ };
130
+ export const getClearedPriceQuery = (route) => {
131
+ const query = { ...route.query };
132
+ if (!query["filters[minPrice]"] && !query["filters[maxPrice]"]) {
133
+ return;
134
+ }
135
+ delete query["filters[minPrice]"];
136
+ delete query["filters[maxPrice]"];
137
+ return query;
138
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scayle/storefront-product-listing",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Collection of essential composables and utilities to work with product listing",
5
5
  "author": "SCAYLE Commerce Engine",
6
6
  "license": "MIT",
@@ -11,18 +11,22 @@
11
11
  },
12
12
  "exports": {
13
13
  ".": {
14
- "types": "./dist/types.d.ts",
15
- "import": "./dist/module.mjs",
16
- "require": "./dist/module.cjs"
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.mjs"
16
+ },
17
+ "./module": {
18
+ "types": "./dist/module.d.ts",
19
+ "require": "./dist/module.cjs",
20
+ "import": "./dist/module.mjs"
17
21
  }
18
22
  },
19
- "main": "./dist/module.cjs",
20
- "types": "./dist/types.d.ts",
23
+ "main": "./dist/index.mjs",
24
+ "types": "./dist/index.d.ts",
21
25
  "files": [
22
26
  "dist"
23
27
  ],
24
28
  "imports": {
25
- "#storefront-product-listing/composables": "./dist/runtime/composables/index.js"
29
+ "#storefront-product-listing": "./dist/runtime/index.js"
26
30
  },
27
31
  "scripts": {
28
32
  "build": "nuxt-module-build build",
@@ -41,9 +45,11 @@
41
45
  },
42
46
  "peerDependencies": {
43
47
  "@nuxt/kit": "^3.13.0",
44
- "@scayle/storefront-nuxt": "^7.85.16",
48
+ "@scayle/storefront-nuxt": "^7.93.0",
45
49
  "@vue/test-utils": "2.4.6",
46
- "@vueuse/core": "11.1.0"
50
+ "@vueuse/core": "11.2.0",
51
+ "fishery": "2.2.2",
52
+ "vue-router": "4.4.5"
47
53
  },
48
54
  "devDependencies": {
49
55
  "@nuxt/kit": "3.13.2",
@@ -51,17 +57,21 @@
51
57
  "@nuxt/schema": "3.13.2",
52
58
  "@nuxt/test-utils": "3.14.4",
53
59
  "@scayle/eslint-config-storefront": "4.3.2",
54
- "@scayle/storefront-nuxt": "7.87.0",
55
- "@types/node": "20.17.0",
60
+ "@scayle/storefront-nuxt": "7.94.2",
61
+ "@types/node": "22.9.0",
56
62
  "@vue/test-utils": "2.4.6",
57
- "@vueuse/core": "11.1.0",
58
- "dprint": "0.47.4",
59
- "eslint": "9.13.0",
63
+ "@vueuse/core": "11.2.0",
64
+ "dprint": "0.47.5",
65
+ "eslint": "9.14.0",
66
+ "happy-dom": "15.11.0",
60
67
  "eslint-formatter-gitlab": "5.1.0",
61
68
  "nuxt": "3.13.2",
62
69
  "publint": "0.2.12",
63
70
  "typescript": "5.6.3",
64
- "vitest": "2.1.3",
65
- "vue-tsc": "2.1.6"
71
+ "vitest": "2.1.4",
72
+ "schema-dts": "1.1.2",
73
+ "vue-router": "4.4.5",
74
+ "fishery": "2.2.2",
75
+ "vue-tsc": "2.1.10"
66
76
  }
67
77
  }
File without changes
File without changes