diff --git a/packages/shopware-6-client/package-lock.json b/packages/shopware-6-client/package-lock.json new file mode 100644 index 000000000..4cf98eb5d --- /dev/null +++ b/packages/shopware-6-client/package-lock.json @@ -0,0 +1,61 @@ +{ + "name": "@shopware-pwa/shopware-6-client", + "version": "0.6.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@types/query-string": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@types/query-string/-/query-string-6.3.0.tgz", + "integrity": "sha512-yuIv/WRffRzL7cBW+sla4HwBZrEXRNf1MKQ5SklPEadth+BKbDxiVG8A3iISN5B3yC4EeSCzMZP8llHTcUhOzQ==", + "dev": true, + "requires": { + "query-string": "*" + } + }, + "axios": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", + "integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==", + "requires": { + "follow-redirects": "^1.10.0" + } + }, + "decode-uri-component": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=" + }, + "follow-redirects": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.2.tgz", + "integrity": "sha512-6mPTgLxYm3r6Bkkg0vNM0HTjfGrOEtsfbhagQvbxDEsEkpNhw582upBaoRZylzen6krEmxXJgt9Ju6HiI4O7BA==" + }, + "idb": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/idb/-/idb-6.0.0.tgz", + "integrity": "sha512-+M367poGtpzAylX4pwcrZIa7cFQLfNkAOlMMLN2kw/2jGfJP6h+TB/unQNSVYwNtP8XqkLYrfuiVnxLQNP1tjA==", + "dev": true + }, + "query-string": { + "version": "6.13.7", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-6.13.7.tgz", + "integrity": "sha512-CsGs8ZYb39zu0WLkeOhe0NMePqgYdAuCqxOYKDR5LVCytDZYMGx3Bb+xypvQvPHVPijRXB0HZNFllCzHRe4gEA==", + "requires": { + "decode-uri-component": "^0.2.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + } + }, + "split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==" + }, + "strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY=" + } + } +} diff --git a/packages/shopware-6-client/package.json b/packages/shopware-6-client/package.json index 997874eaf..8b1fd2110 100644 --- a/packages/shopware-6-client/package.json +++ b/packages/shopware-6-client/package.json @@ -26,7 +26,8 @@ "query-string": "^6.13.7" }, "devDependencies": { - "@types/query-string": "^6.3.0" + "@types/query-string": "^6.3.0", + "idb": "^6.0.0" }, "license": "MIT", "publishConfig": { diff --git a/packages/shopware-6-client/src/index.inner.ts b/packages/shopware-6-client/src/index.inner.ts new file mode 100644 index 000000000..363f54c59 --- /dev/null +++ b/packages/shopware-6-client/src/index.inner.ts @@ -0,0 +1,45 @@ +import { defaultInstance, ConfigChangedArgs } from "./apiService"; +import { ClientSettings } from "./settings"; +export { ClientSettings } from "./settings"; +export { + createInstance, + ConfigChangedArgs, + ShopwareApiInstance, +} from "./apiService"; + +export * from "./services/categoryService"; +export * from "./services/productService"; +export * from "./services/customerService"; +export * from "./services/contextService"; +export * from "./services/cartService"; +export * from "./services/navigationService"; +export * from "./services/pageService"; +export * from "./services/checkoutService"; +export * from "./services/pluginService"; +export * from "./services/searchService"; +export * from "./services/formsService"; +export * from "./endpoints"; + +/** + * @beta + */ +export const config: ClientSettings = defaultInstance.config; +/** + * Setup configuration. Merge default values with provided in param. + * This method will override existing config. For config update invoke **update** method. + * @beta + */ +export const setup: (config?: ClientSettings) => void = defaultInstance.setup; + +/** + * Update current configuration. This will change only provided values. + * @beta + */ +export const update: (config?: ClientSettings) => void = defaultInstance.update; + +/** + * @beta + */ +export const onConfigChange: ( + fn: (context: ConfigChangedArgs) => void +) => void = defaultInstance.onConfigChange; diff --git a/packages/shopware-6-client/src/index.ts b/packages/shopware-6-client/src/index.ts index 363f54c59..8bad2f5c4 100644 --- a/packages/shopware-6-client/src/index.ts +++ b/packages/shopware-6-client/src/index.ts @@ -1,45 +1,20 @@ -import { defaultInstance, ConfigChangedArgs } from "./apiService"; -import { ClientSettings } from "./settings"; -export { ClientSettings } from "./settings"; -export { - createInstance, - ConfigChangedArgs, - ShopwareApiInstance, -} from "./apiService"; +export * from "./index.inner"; -export * from "./services/categoryService"; -export * from "./services/productService"; -export * from "./services/customerService"; -export * from "./services/contextService"; -export * from "./services/cartService"; -export * from "./services/navigationService"; -export * from "./services/pageService"; -export * from "./services/checkoutService"; -export * from "./services/pluginService"; -export * from "./services/searchService"; -export * from "./services/formsService"; -export * from "./endpoints"; +// Explicitly import offline overrides and alias them -/** - * @beta - */ -export const config: ClientSettings = defaultInstance.config; -/** - * Setup configuration. Merge default values with provided in param. - * This method will override existing config. For config update invoke **update** method. - * @beta - */ -export const setup: (config?: ClientSettings) => void = defaultInstance.setup; +// Product Listing +import { getCategoryProducts as offlineGetCategoryProducts } from "./offline-services/productService"; -/** - * Update current configuration. This will change only provided values. - * @beta - */ -export const update: (config?: ClientSettings) => void = defaultInstance.update; +const getCategoryProducts = offlineGetCategoryProducts; -/** - * @beta - */ -export const onConfigChange: ( - fn: (context: ConfigChangedArgs) => void -) => void = defaultInstance.onConfigChange; +// Product Search +import { + searchProducts as offlineSearchProducts, + searchSuggestedProducts as offlineSearchSuggestedProducts, +} from "./offline-services/searchService"; + +const searchProducts = offlineSearchProducts; +const searchSuggestedProducts = offlineSearchSuggestedProducts; + +// Re-export them with original name, because local modules have priority in bundler +export { searchProducts, searchSuggestedProducts, getCategoryProducts }; diff --git a/packages/shopware-6-client/src/offline-services/productService.ts b/packages/shopware-6-client/src/offline-services/productService.ts new file mode 100644 index 000000000..6ef367af1 --- /dev/null +++ b/packages/shopware-6-client/src/offline-services/productService.ts @@ -0,0 +1,73 @@ +import * as innerClient from "./../index.inner"; + +import { ShopwareSearchParams } from "@shopware-pwa/commons/interfaces/search/SearchCriteria"; +import { ProductListingResult } from "@shopware-pwa/commons/interfaces/response/ProductListingResult"; +import { Product } from "@shopware-pwa/commons/interfaces/models/content/product/Product"; + +import { defaultInstance, ShopwareApiInstance } from "../apiService"; + +import { open } from "../offline/store/DatabaseHandle"; +import { IDBPDatabase } from "idb"; + +import { createProductListingResult } from "../offline/factory/ProductListingResultFactory"; + +import { synchroniseProducts } from "../offline/sync/ProductSynchroniser"; + +/** + * Get default amount of products and listing configuration for given category + * + * @throws ClientApiError + * @beta + */ +export const getCategoryProducts = async function ( + categoryId: string, + criteria?: ShopwareSearchParams, + contextInstance: ShopwareApiInstance = defaultInstance +): Promise { + let db: IDBPDatabase; + + try { + db = await open(); + // throw new Error("e"); + } catch (e) { + // await synchroniseProducts(1) + // await synchroniseProducts(2) + // await synchroniseProducts(3) + // await synchroniseProducts(4) + return innerClient.getCategoryProducts( + categoryId, + criteria, + contextInstance + ); + } + + let keyRange = IDBKeyRange.only(categoryId); + + let total = await db + .transaction("product_search_data") + .store.index("category") + .count(keyRange); + let cursor = await db + .transaction("product_search_data") + .store.index("category") + .openCursor(keyRange); + + let products: Array = []; + + let limit: number = criteria?.limit || 10; + let page: number = criteria?.p || 1; + let start: number = (page - 1) * limit; + + // If start is 0, do nothing, otherwise advance to start + cursor = start > 0 ? (await cursor?.advance(start)) || null : cursor; + + let count = 0; + + while (cursor && count < limit) { + products.push(cursor.value); + cursor = await cursor.continue(); + count++; + } + + return createProductListingResult(products, total, limit, page); +}; diff --git a/packages/shopware-6-client/src/offline-services/searchService.ts b/packages/shopware-6-client/src/offline-services/searchService.ts new file mode 100644 index 000000000..886c304e8 --- /dev/null +++ b/packages/shopware-6-client/src/offline-services/searchService.ts @@ -0,0 +1,62 @@ +import * as innerClient from "./../index.inner"; + +import { open } from "./../offline/store/DatabaseHandle"; +import { handleRequest } from "./../offline/criteria/RequestCriteriaHandler"; + +import { ProductListingResult } from "@shopware-pwa/commons/interfaces/response/ProductListingResult"; +import { ShopwareSearchParams } from "@shopware-pwa/commons/interfaces/search/SearchCriteria"; +import { defaultInstance, ShopwareApiInstance } from "./../apiService"; + +import { IDBPDatabase } from "idb"; + +export async function searchProducts( + criteria?: ShopwareSearchParams, + contextInstance: ShopwareApiInstance = defaultInstance +): Promise { + let db: IDBPDatabase; + + try { + db = await open(); + } catch (e) { + return innerClient.searchProducts(criteria, contextInstance); + } + + let elements = await db.transaction("product").store.getAll(); + + /* This is where the magic happens */ + let productResult: ProductListingResult = await handleRequest( + elements, + criteria + ); + + return productResult; +} + +/** + * Search for suggested products based on criteria. + * From: Shopware 6.4 + * + * @beta + */ +export async function searchSuggestedProducts( + criteria?: ShopwareSearchParams, + contextInstance: ShopwareApiInstance = defaultInstance +): Promise { + let db: IDBPDatabase; + + try { + db = await open(); + } catch (e) { + return innerClient.searchSuggestedProducts(criteria, contextInstance); + } + + let elements = await db.transaction("product").store.getAll(); + + /* This is where the magic happens */ + let productResult: ProductListingResult = await handleRequest( + elements, + criteria + ); + + return productResult; +} diff --git a/packages/shopware-6-client/src/offline/criteria/CriteriaBuilder.ts b/packages/shopware-6-client/src/offline/criteria/CriteriaBuilder.ts new file mode 100644 index 000000000..a7d4e791a --- /dev/null +++ b/packages/shopware-6-client/src/offline/criteria/CriteriaBuilder.ts @@ -0,0 +1,22 @@ +import { ShopwareSearchParams } from "@shopware-pwa/commons/interfaces/search/SearchCriteria"; + +import FilterInterface from "./filter/FilterInterface"; + +import TermFilter from "./filter/TermFilter"; +// import RangeFilter from "./filter/RangeFilter" +// import EqualsFilter from "./filter/EqualsFilter" + +const buildFilters = function ( + criteria: ShopwareSearchParams, + filters: Array = [] +): Array { + console.log(criteria); + + filters.push(new TermFilter(criteria?.query || "")); + // filters.push(new RangeFilter('ratingAverage', { gt: 3 })) + // filters.push(new EqualsFilter('id', 'c6a351be9ad54596b1062196f7fd7240')) + + return filters; +}; + +export { buildFilters }; diff --git a/packages/shopware-6-client/src/offline/criteria/RequestCriteriaHandler.ts b/packages/shopware-6-client/src/offline/criteria/RequestCriteriaHandler.ts new file mode 100644 index 000000000..53616ac8b --- /dev/null +++ b/packages/shopware-6-client/src/offline/criteria/RequestCriteriaHandler.ts @@ -0,0 +1,56 @@ +import { ShopwareSearchParams } from "@shopware-pwa/commons/interfaces/search/SearchCriteria"; + +import { ProductListingResult } from "@shopware-pwa/commons/interfaces/response/ProductListingResult"; +import { Product } from "@shopware-pwa/commons/interfaces/models/content/product/Product"; + +import { createProductListingResult } from "../factory/ProductListingResultFactory"; + +import { buildFilters } from "./CriteriaBuilder"; + +import FilterInterface from "./filter/FilterInterface"; + +const handleRequest = async function ( + allElements: Array, + criteria?: ShopwareSearchParams +): Promise { + let filters: Array = []; + if (typeof criteria !== "undefined") { + filters = buildFilters(criteria); + } + + /* Build Criteria */ + + console.time("parseCriteria"); + + /* Init empty result set */ + let elements: Array = []; + + if (typeof criteria !== "undefined" && criteria.query) { + /* Execute filters */ + elements = _applyFilters(filters, allElements); + } + + console.timeEnd("parseCriteria"); + + return createProductListingResult(elements, criteria); +}; + +/* Simple method for filtering */ +const _applyFilters = function ( + filters: Array, + elements: Array +): Array { + let filteredElements = elements.filter((product: Product) => { + return filters + .map((filter): boolean => { + return filter.supports(product) && filter.match(product); + }) + .reduce((evaluated, current) => { + return evaluated && current; + }); + }); + + return filteredElements; +}; + +export { handleRequest }; diff --git a/packages/shopware-6-client/src/offline/criteria/filter/EqualsFilter.ts b/packages/shopware-6-client/src/offline/criteria/filter/EqualsFilter.ts new file mode 100644 index 000000000..9b27e9a30 --- /dev/null +++ b/packages/shopware-6-client/src/offline/criteria/filter/EqualsFilter.ts @@ -0,0 +1,22 @@ +import FilterInterface from "./FilterInterface"; + +export default class TermFilter implements FilterInterface { + type: "term"; + + field: string; + + value; + + constructor(field: string, value) { + this.field = field; + this.value = value; + } + + supports(element) { + return this.field in element; + } + + match(element): boolean { + return element[this.field] === this.value; + } +} diff --git a/packages/shopware-6-client/src/offline/criteria/filter/FilterInterface.ts b/packages/shopware-6-client/src/offline/criteria/filter/FilterInterface.ts new file mode 100644 index 000000000..fca1d12cb --- /dev/null +++ b/packages/shopware-6-client/src/offline/criteria/filter/FilterInterface.ts @@ -0,0 +1,15 @@ +interface FilterInterface { + type: string; + supports: SupportsFunction; + match: MatchFunction; +} + +interface SupportsFunction { + (element): boolean; +} + +interface MatchFunction { + (element): boolean; +} + +export default FilterInterface; diff --git a/packages/shopware-6-client/src/offline/criteria/filter/RangeFilter.ts b/packages/shopware-6-client/src/offline/criteria/filter/RangeFilter.ts new file mode 100644 index 000000000..f8d82f95b --- /dev/null +++ b/packages/shopware-6-client/src/offline/criteria/filter/RangeFilter.ts @@ -0,0 +1,39 @@ +import FilterInterface from "./FilterInterface"; + +export const LTE = "lte"; +export const LT = "lt"; +export const GTE = "gte"; +export const GT = "gt"; + +export default class RangeFilter implements FilterInterface { + type: "range"; + + field: string; + + parameters; + + constructor(field: string, parameters) { + this.field = field; + this.parameters = parameters; + } + + supports(element) { + return this.field in element; + } + + match(element): boolean { + if (LTE in this.parameters) { + return element[this.field] <= this.parameters[LTE]; + } + if (LT in this.parameters) { + return element[this.field] < this.parameters[LT]; + } + if (GTE in this.parameters) { + return element[this.field] >= this.parameters[GTE]; + } + if (GT in this.parameters) { + return element[this.field] > this.parameters[GT]; + } + return false; + } +} diff --git a/packages/shopware-6-client/src/offline/criteria/filter/TermFilter.ts b/packages/shopware-6-client/src/offline/criteria/filter/TermFilter.ts new file mode 100644 index 000000000..55adfd7e5 --- /dev/null +++ b/packages/shopware-6-client/src/offline/criteria/filter/TermFilter.ts @@ -0,0 +1,19 @@ +import FilterInterface from "./FilterInterface"; + +export default class TermFilter implements FilterInterface { + type: "term"; + + term: string; + + constructor(term: string) { + this.term = term; + } + + supports(element) { + return typeof element.name !== "undefined"; + } + + match(element): boolean { + return element?.name?.includes(this.term); + } +} diff --git a/packages/shopware-6-client/src/offline/factory/ProductListingResultFactory.ts b/packages/shopware-6-client/src/offline/factory/ProductListingResultFactory.ts new file mode 100644 index 000000000..8c2e3c470 --- /dev/null +++ b/packages/shopware-6-client/src/offline/factory/ProductListingResultFactory.ts @@ -0,0 +1,25 @@ +import { ProductListingResult } from "@shopware-pwa/commons/interfaces/response/ProductListingResult"; +import { Product } from "@shopware-pwa/commons/interfaces/models/content/product/Product"; + +const createProductListingResult = function ( + elements: Array, + total: number, + limit: number, + page: number +): ProductListingResult { + return { + apiAlias: "product_listing", + total: total, + elements, + sorting: "score", + sortings: [], + limit, + page, + currentFilters: { + search: "", + }, + aggregations: {}, + }; +}; + +export { createProductListingResult }; diff --git a/packages/shopware-6-client/src/offline/store/DatabaseHandle.ts b/packages/shopware-6-client/src/offline/store/DatabaseHandle.ts new file mode 100644 index 000000000..32a57c4e6 --- /dev/null +++ b/packages/shopware-6-client/src/offline/store/DatabaseHandle.ts @@ -0,0 +1,28 @@ +import { IDBPDatabase, openDB } from "idb"; + +import { upgrade as upgradeProductSearchDataStore } from "./ProductSearchDataStore"; + +const SHOPWARE_PWA_DATABASE_NAME = "shopware_pwa_data"; +const SHOPWARE_PWA_DATABASE_VERSION = 1; + +const open = async function (): Promise { + if (typeof window == "undefined" || !window.indexedDB) { + throw new Error( + `Error initialising database ${SHOPWARE_PWA_DATABASE_NAME} - window.indexedDB is not available` + ); + } + + return openDB(SHOPWARE_PWA_DATABASE_NAME, SHOPWARE_PWA_DATABASE_VERSION, { + upgrade(database: IDBPDatabase, oldVerison, newVersion, transaction) { + // Create product search data store + upgradeProductSearchDataStore( + database, + oldVerison, + newVersion, + transaction + ); + }, + }); +}; + +export { open }; diff --git a/packages/shopware-6-client/src/offline/store/ProductSearchDataStore.ts b/packages/shopware-6-client/src/offline/store/ProductSearchDataStore.ts new file mode 100644 index 000000000..a26635f71 --- /dev/null +++ b/packages/shopware-6-client/src/offline/store/ProductSearchDataStore.ts @@ -0,0 +1,34 @@ +import { UpgradeFunction } from "./StoreInterface"; + +/** + * Name of the product store + */ +const name: string = "product_search_data"; + +/** + * Abstraction for creation of product store + */ +const upgrade: UpgradeFunction = function ( + database, + oldVersion, + newVersion, + transaction +) { + if (oldVersion < 1) { + let productSearchDataStore = database.createObjectStore(name, { + keyPath: "id", + }); + + productSearchDataStore.createIndex("name", "name"); + productSearchDataStore.createIndex("rating", "ratingAverage"); + productSearchDataStore.createIndex("listingPrice", "listingPrice"); + productSearchDataStore.createIndex("category", "categories", { + multiEntry: true, + }); + productSearchDataStore.createIndex("manufacturer", "manufacturerId"); + productSearchDataStore.createIndex("shippingFree", "shippingFree"); + productSearchDataStore.createIndex("productNumber", "productNumber"); + } +}; + +export { name, upgrade }; diff --git a/packages/shopware-6-client/src/offline/store/ProductStore.ts b/packages/shopware-6-client/src/offline/store/ProductStore.ts new file mode 100644 index 000000000..7520b7bee --- /dev/null +++ b/packages/shopware-6-client/src/offline/store/ProductStore.ts @@ -0,0 +1,24 @@ +import { UpgradeFunction } from "./StoreInterface"; + +/** + * Name of the product store + */ +const name: string = "product"; + +/** + * Abstraction for creation of product store + */ +const upgrade: UpgradeFunction = function ( + database, + oldVersion, + newVersion, + transaction +) { + if (oldVersion < 1) { + database.createObjectStore(name, { + keyPath: "id", + }); + } +}; + +export { name, upgrade }; diff --git a/packages/shopware-6-client/src/offline/store/StoreInterface.ts b/packages/shopware-6-client/src/offline/store/StoreInterface.ts new file mode 100644 index 000000000..d04112cc5 --- /dev/null +++ b/packages/shopware-6-client/src/offline/store/StoreInterface.ts @@ -0,0 +1,12 @@ +import { IDBPDatabase } from "idb"; + +interface UpgradeFunction { + ( + database: IDBPDatabase, + oldVerison: number, + newVersion: number | null, + transaction + ): void; +} + +export { UpgradeFunction }; diff --git a/packages/shopware-6-client/src/offline/sync/productSynchroniser.ts b/packages/shopware-6-client/src/offline/sync/productSynchroniser.ts new file mode 100644 index 000000000..7da7388dd --- /dev/null +++ b/packages/shopware-6-client/src/offline/sync/productSynchroniser.ts @@ -0,0 +1,80 @@ +import { IDBPDatabase } from "idb"; +import { invokePost } from "../../index"; +import { Product } from "@shopware-pwa/commons/interfaces/models/content/product/Product"; +import { open } from "../store/DatabaseHandle"; + +const synchroniseProducts = async function (page: number = 0) { + let products = await invokePost({ + address: "/store-api/v4/product", + payload: { + limit: 500, + page, + associations: { + categories: {}, + categoriesRo: {}, + cover: {}, + }, + includes: { + product: [ + "name", + "ratingAverage", + "calculatedPrice", + "calculatedListingPrice", + "manufacturerId", + "categories", + "categoriesRo", + "id", + "translated", + "shippingFree", + "productNumber", + "seoUrls", + "cover", + ], + product_media: ["media"], + media: ["thumbnails", "width", "height", "url"], + calculated_price: ["unitPrice"], + category: ["id"], + }, + }, + }); + + /** + * @todo Optimise storage, by removing apiAlias fields, which are not used by the search + */ + + let db: IDBPDatabase; + + try { + db = await open(); + } catch (e) { + console.warn("Indexing not possible, not able to open indexedDB"); + return; + } + + let writeProducts = db.transaction("product_search_data", "readwrite"); + + products.data.elements.forEach((product: Product) => { + writeProducts.store.put({ + id: product.id, + name: product.translated.name, + ratingAverage: product.ratingAverage + ? product.ratingAverage.toString() + : "", + listingPrice: product.calculatedListingPrice.from.unitPrice.toString(), + calculatedListingPrice: product.calculatedListingPrice, + calculatedPrice: product.calculatedPrice, + categories: product.categoriesRo + ? product.categoriesRo.map((c) => { + return c.id; + }) + : product.categories.map((c) => { + return c.id; + }), + manufacturerId: product.manufacturerId, + shippingFree: product.shippingFree ? 1 : 0, + productNumber: product.productNumber, + }); + }); +}; + +export { synchroniseProducts };