diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..2e81d7877 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,8 @@ +[submodule "src/modules/vsf-cache-nginx"] + path = src/modules/vsf-cache-nginx + url = https://github.com/new-fantastic/vsf-cache-nginx.git + branch = master +[submodule "src/modules/vsf-cache-varnish"] + path = src/modules/vsf-cache-varnish + url = https://github.com/new-fantastic/vsf-cache-varnish.git + branch = master diff --git a/.gitpod.yml b/.gitpod.yml new file mode 100644 index 000000000..acf0da7d5 --- /dev/null +++ b/.gitpod.yml @@ -0,0 +1,19 @@ +tasks: + - init: | + yarn install + echo '{ "api": { "url": "https://next.storefrontcloud.io" }}' > config/local.json + yarn build + command: yarn dev + +ports: + - port: 3000 + onOpen: open-preview + +vscode: + extensions: + - octref.vetur@0.23.0:TEzauMObB6f3i2JqlvrOpA== + - dbaeumer.vscode-eslint@2.0.15:/v3eRFwBI38JLZJv5ExY5g== + - eg2.vscode-npm-script@0.3.11:peDPJqeL8FmmJiabU4fAJQ== + - formulahendry.auto-close-tag@0.5.6:oZ/8R2VhZEhkHsoeO57hSw== + - formulahendry.auto-rename-tag@0.1.1:lKCmLIZAiCM0M8AjDnwCLQ== + - dariofuzinato.vue-peek@1.0.2:oYJg0oZA/6FBnFfW599HRg== diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..f599e28b8 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +10 diff --git a/.travis.yml b/.travis.yml index 6364ccfcd..da072b925 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,6 +7,7 @@ cache: - node_modules install: + - git clone --quiet --single-branch --branch master https://github.com/DivanteLtd/vsf-default.git ./src/themes/default - yarn jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 72dbd429b..a9c6900c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,86 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.12.0] - 2020.06.01 + +### Added + +- Add `vsf-capybara` support as a dependency and extend CLI to support customization - @psmyrek (#4209) +- Support theme configuration via CLI - @psmyrek (#4395) +- Allow parent_ids field on product as an alternative to urlpath based breadcrumb navigation (#4219) +- Pass the original item_id when updating/deleting a cart entry @carlokok (#4218) +- Separating endpoints for CSR/SSR - @Fifciu (#2861) +- Added short hands for version and help flags - @jamesgeorge007 (#3946) +- Add `or` operator for Elasticsearch filters in `quickSearchByQuery` and use exists if value is `null` - @cewald (#3960) +- Add unified fetch in mappingFallback for all searched entities - @gibkigonzo (#3942) +- add npm-run-all for parallel build - @gibkigonzo (#3819) +- Add OutputCaching support for x-vs-store-code - @benjick (#3979) +- The new search adapter `api-search-query` has been added. When you switch to it, by setting the `config.server.api = "api-search-query"` the ElasticSearch query is being built in the [`vue-storefront-api`](https://github.com/DivanteLtd/vue-storefront-api/pull/390) which saves around 400kB in the bundle size as `bodybuilder` is no longer needed in the frontend - @pkarw - #2167 +- This new `api-search-query` adapter supports the `response_format` query parameter which now is sent to the `/api/catalog` endpoint. Currently there is just one additional format supported: `response_format=compact`. When used, the response format got optimized by: a) remapping the results, removing the `_source` from the `hits.hits`; b) compressing the JSON fields names according to the `config.products.fieldsToCompact`; c) removing the JSON fields from the `product.configurable_children` when their values === parent product values; overall response size reduced over -70% - @pkarw +- The `amp-renderer` module has been disabled by default to save the bundle size; If you'd like to enable it uncomment the module from the `src/modules` and uncomment the `product-amp` and `category-amp` links that are added to the `` section in the `src/themes/default/Product.vue` and `src/themes/default/Category.vue` +- Reset Password confirmation page - @Fifciu (#2576) +- Add `Intl.NumberFormat()`/`toLocaleString()` via polyfill support in NodeJs - @cewald (#3836, #4040) +- Added `saveBandwidthOverCache` parameter for skipping caching for products data - @andrzejewsky (#3706) +- New zoom effect for product gallery images - @Michal-Dziedzinski (#2755) +- Add custom currency separators and amount of fraction digits - @EndPositive (#3553) +- Product Page Schema implementation as JSON-LD - @Michal-Dziedzinski (#3704) +- Add `/cache-version.json` route to get current cache version +- Built-in module for detecting device type based on UserAgent with SSR support - @Fifciu +- Update to `storefront-query-builder` version `1.0.0` - @cewald (#4234) +- Move generating files from webpack config to script @gibkigonzo (#4236) +- Add correct type matching to `getConfigurationMatchLevel` - @cewald (#4241) +- Support `useSpecificImagePaths` with `useExactUrlsNoProxy` - @cewald (#4243) +- Adds module which handles cache invalidation for Fastly. - @gibkigonzo (#4096) +- Add vsf-cache-nginx and vsf-cache-varnish modules - @gibkigonzo (#4096) +- Added meta info for CMS pages from Magento @mdanilowicz (#4392) +- Add useful core events to server & logger - @cewald (#4419) + +### Fixed + +- Fixed `resultPorcessor` typo - @psmyrek +- Negative price has doubled minus sign - @psmyrek (#4353) +- Fixed Search product fails for category filter when categoryId is string - @adityasharma7 (#3929) +- Revert init filters in Vue app - @gibkigonzo (#3929) +- All categories disappearing if you add the child category name to includeFields - @1070rik (#4015) +- Fix overlapping text in PersonalDetails component - @jakubmakielkowski (#4024) +- Redirect from checkout to home with a proper store code - @Fifciu +- Added back error notification when user selects invalid configuration - @1070rik (#4033) +- findConfigurableChildAsync - return best match for configurable variant - @gibkigonzo, @cewald (#4042, #4216) +- use storeCode for mappingFallback url - @gibkigonzo (#4050) +- `getVariantWithLowestPrice` uses inexistent `final_price` property - @cewald (#4091) +- Fixed `NOT_ALLOWED_SSR_EXTENSIONS_REGEX` to only match with file extensions having a dot - @haelbichalex (#4100) +- Fixed problem with not showing error message when placing an order fails - @qiqqq +- Invoking afterCacheInvalidated server hook in a proper moment - @Fifciu (#4176) +- Fixed `cart/isVirtualCart` to return `false` when cart is empty - @haelbichalex(#4182) +- Use `setProductGallery` in `product/setCurrent` to use logic of the action - @cewald (#4153) +- Use same data format in getConfigurationMatchLevel - @gibkigonzo (#4208) +- removed possible memory leak in ssr - @resubaka (#4247) +- Bugfix for reactivity of `current_configuration` in `populateProductConfigurationAsync` - @cewald (#4258) +- Bugfix for build exception in Node v13.13+ - @cewald (#4249) +- Convert option ids to string while comparing them in `getProductConfiguration` - @gibkigonzo (#4484) +- change value to number in price filter - @gibkigonzo (#4478) + +### Changed / Improved + +- Optimized `translation.processor` to process only enabled locale CSV files - @pkarw (#3950) +- Remove commit register mapping - @gibkigonzo (#3875) +- Improved method `findConfigurableChildAsync` - find variant with lowest price - @gibkigonzo (#3939) +- Removed `product/loadConfigurableAttributes` calls - @andrzejewsky (#3336) +- Removed unused locales in disabled multistore - @gibkigonzo (#4072) +- Optimized attributes loading - @andrzejewsky (#3948) +- Cart optimization can now be used regardless if entity optimization is enabled - @juho-jaakkola (#4198) +- Improve typescript support for test utils - @resubaka (#4067) +- Removed `product/loadConfigurableAttributes` calls - @andrzejewsky, @gibkigonzo (#3336) +- Disable `mapFallback` url by default - @gibkigonzo(#4092) +- Include token in pricing sync - @carlokok (#4156) +- Move 'graphql' search adapter from core to src (deprecated) - @gibkigonzo (#4214) +- Homepage, new products query, uses now `new` attribute - @mdanilwoicz +- Refactor product module, more info in upgrade notes- @gibkigonzo (#3952, #4459) +- Move default theme to separate repository https://github.com/DivanteLtd/vsf-default - @gibkigonzo (#4255) +- add two numbers after dot to price by default, calculate default price for bundle or grouped main product, update typing, add fallback to attribute options - @gibkigonzo (#4476) +- udpate yarn and filter shipping methods for instant checkout - @gibkigonzo (#4480) +- add attribute metadata search query, add parentId - @gibkigonzo (#4491) + ## [1.11.4] - 2020.05.26 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 00130e0de..635fac2bf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,6 +3,10 @@ Already a JavaScript/Vue.js developer? Pick an issue, push a pull request (PR) and instantly become a member of the vue-storefront contributors community. We've marked some issues as "Easy first pick" to make it easier for newcomers to begin! +You can start a ready-to-code development environment in your browser, by clicking the button below: + +[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/from-referrer/) + Thank you for your interest in, and engagement! Before you type an issue please read about out [release lifecycle](https://docs.vuestorefront.io/guide/basics/release-cycle.html). diff --git a/README.md b/README.md index 0855fdce5..8c44f38e3 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Branch stable Branch Develop ![Branch Develop](https://img.shields.io/badge/community%20chat-slack-FF1493.svg) +[![Gitpod Ready-to-Code](https://img.shields.io/badge/Gitpod-Ready--to--Code-blue?logo=gitpod)](https://gitpod.io/from-referrer/) Vue Storefront is a standalone PWA storefront for your eCommerce, possible to connect with any eCommerce backend (eg. Magento, Pimcore/CoreShop, [BigCommerce](https://github.com/DivanteLtd/bigcommerce2vuestorefront), Prestashop or Shopware) through the API. diff --git a/config/default.json b/config/default.json index bfb3c8a57..73bd7cc22 100644 --- a/config/default.json +++ b/config/default.json @@ -68,6 +68,20 @@ "config": {} } }, + "initialResources": [ + { + "filters": ["vsf-newsletter-modal", "vsf-languages-modal", "vsf-layout-empty", "vsf-layout-minimal", "vsf-order-confirmation", "vsf-search-panel"], + "type": "script", + "onload": true, + "rel": "prefetch" + }, + { + "filters": ["vsf-category", "vsf-home", "vsf-not-found", "vsf-error", "vsf-product", "vsf-cms", "vsf-checkout", "vsf-compare", "vsf-my-account", "vsf-static", "vsf-reset-password"], + "type": "script", + "onload": true, + "rel": "prefetch" + } + ], "staticPages": { "updateOnRequest": true, "destPath": "static" @@ -91,7 +105,8 @@ "port": 8080 }, "api": { - "url": "http://localhost:8080" + "url": "http://localhost:8080", + "saveBandwidthOverCache": true }, "elasticsearch": { "httpAuth": "", @@ -284,12 +299,14 @@ "is_comparable", "options", "tier_prices" - ] + ], + "loadByAttributeMetadata": false }, "productList": { "sort": "updated_at:desc", "includeFields": [ "activity", + "configurable_children.attributes", "configurable_children.id", "configurable_children.final_price", "configurable_children.color", @@ -328,18 +345,37 @@ "*small_image" ], "excludeFields": [ + "attribute_set_id", "configurable_options", "description", "sgn", "*.sgn", "msrp_display_actual_price_type", "*.msrp_display_actual_price_type", - "required_options" + "required_options", + "media_gallery", + "stock.use_config_min_qty", + "stock.use_config_notify_stock_qty", + "stock.stock_id", + "stock.use_config_backorders", + "stock.use_config_enable_qty_inc", + "stock.enable_qty_increments", + "stock.use_config_manage_stock", + "stock.use_config_min_sale_qty", + "stock.notify_stock_qty", + "stock.use_config_max_sale_qty", + "stock.use_config_max_sale_qty", + "stock.qty_increments", + "stock.stock_status_changed_auto", + "stock.show_default_notification_message", + "stock.use_config_qty_increments", + "stock.is_decimal_divided" ] }, "productListWithChildren": { "includeFields": [ "activity", + "configurable_children.attributes", "configurable_children.image", "configurable_children.sku", "configurable_children.price", @@ -380,12 +416,30 @@ "url_key" ], "excludeFields": [ + "attribute_set_id", "description", "sgn", "*.sgn", "msrp_display_actual_price_type", "*.msrp_display_actual_price_type", - "required_options" + "required_options", + "media_gallery", + "stock.use_config_min_qty", + "stock.use_config_notify_stock_qty", + "stock.stock_id", + "stock.use_config_backorders", + "stock.use_config_enable_qty_inc", + "stock.enable_qty_increments", + "stock.use_config_manage_stock", + "stock.use_config_min_sale_qty", + "stock.notify_stock_qty", + "stock.use_config_max_sale_qty", + "stock.use_config_max_sale_qty", + "stock.qty_increments", + "stock.stock_status_changed_auto", + "stock.show_default_notification_message", + "stock.use_config_qty_increments", + "stock.is_decimal_divided" ] }, "review": { @@ -519,6 +573,34 @@ "disablePersistentAttributesCache": false }, "products": { + "fieldsToCompact": { + "minimal_price": "mp", + "has_options": "ho", + "url_key": "u", + "status": "s", + "required_options": "ro", + "name": "nm", + "tax_class_id": "tci", + "description": "desc", + "minimal_regular_price": "mrp", + "final_price": "fp", + "price": "p", + "special_price": "sp", + "original_final_price": "ofp", + "original_price": "op", + "original_special_price": "osp", + "final_price_incl_tax": "fpit", + "original_price_incl_tax": "opit", + "price_incl_tax": "pit", + "special_price_incl_tax": "spit", + "final_price_tax": "fpt", + "price_tax": "pt", + "special_price_tax": "spt", + "original_price_tax": "opt", + "image": "i", + "small_image": "si", + "thumbnail": "t" + }, "disablePersistentProductsCache": true, "useMagentoUrlKeys": true, "setFirstVarianAsDefaultInURL": false, @@ -529,6 +611,7 @@ "listOutOfStockProducts": true, "preventConfigurableChildrenDirectAccess": true, "alwaysSyncPlatformPricesOver": false, + "alwaysSyncPricesClientSide": false, "clearPricesBeforePlatformSync": false, "waitForPlatformSync": false, "setupVariantByAttributeCode": true, @@ -625,9 +708,11 @@ }, "users": { "autoRefreshTokens": true, + "loginAfterCreatePassword": true, "endpoint": "/api/user", "history_endpoint": "/api/user/order-history?token={{token}}&pageSize={{pageSize}}¤tPage={{currentPage}}", "resetPassword_endpoint": "/api/user/reset-password", + "createPassword_endpoint": "http://localhost:8080/api/user/create-password", "changePassword_endpoint": "/api/user/change-password?token={{token}}", "login_endpoint": "/api/user/login", "create_endpoint": "/api/user/create", @@ -688,6 +773,9 @@ "defaultLocale": "en-US", "currencyCode": "USD", "currencySign": "$", + "currencyDecimal": "", + "currencyGroup": "", + "fractionDigits": 2, "priceFormat": "{sign}{amount}", "dateFormat": "HH:mm D/M/YYYY", "fullCountryName": "United States", @@ -756,8 +844,8 @@ "newProducts": { "filter": [ { - "key": "category.name", - "value": { "eq": "Tees" } + "key": "new", + "value": { "eq": 1 } } ] }, @@ -769,5 +857,19 @@ } ] } + }, + "urlModule": { + "enableMapFallbackUrl": false, + "endpoint": "/api/url", + "map_endpoint": "/api/url/map" + }, + "fastly": { + "enabled":false + }, + "nginx": { + "enabled":false + }, + "varnish": { + "enabled":false } } diff --git a/core/app.ts b/core/app.ts index 8dfb969e1..72fc80ed6 100755 --- a/core/app.ts +++ b/core/app.ts @@ -25,9 +25,10 @@ import { enabledModules } from './modules-entry' import globalConfig from 'config' import { injectReferences } from '@vue-storefront/core/lib/modules' import { coreHooksExecutors } from '@vue-storefront/core/hooks' -import { registerClientModules } from 'src/modules/client'; +import { registerClientModules } from 'src/modules/client' import initialStateFactory from '@vue-storefront/core/helpers/initialStateFactory' -import { createRouter, createRouterProxy } from '@vue-storefront/core/helpers/router'; +import { createRouter, createRouterProxy } from '@vue-storefront/core/helpers/router' +import { checkForIntlPolyfill } from '@vue-storefront/i18n/intl' const stateFactory = initialStateFactory(store.state) @@ -49,7 +50,12 @@ const createApp = async (ssrContext, config, storeCode = null): Promise<{app: Vu store.state.__DEMO_MODE__ = (config.demomode === true) if (ssrContext) { // @deprecated - we shouldn't share server context between requests - Vue.prototype.$ssrRequestContext = { output: { cacheTags: ssrContext.output.cacheTags } } + Vue.prototype.$ssrRequestContext = { + output: { + cacheTags: ssrContext.output.cacheTags + }, + userAgent: ssrContext.server.request.headers['user-agent'] + } Vue.prototype.$cacheTags = ssrContext.output.cacheTags } @@ -101,6 +107,8 @@ const createApp = async (ssrContext, config, storeCode = null): Promise<{app: Vu registerModules(enabledModules, appContext) registerTheme(globalConfig.theme, app, routerProxy, store, globalConfig, ssrContext) + await checkForIntlPolyfill(storeView) + coreHooksExecutors.afterAppInit() // @deprecated from 2.0 EventBus.$emit('application-after-init', app) diff --git a/core/build/theme-path.js b/core/build/theme-path.js index 9e291c1b5..209ed6a32 100644 --- a/core/build/theme-path.js +++ b/core/build/theme-path.js @@ -12,7 +12,15 @@ if (detectInstalled.sync(config.theme, { local: true })) { else { themeName = themeName.replace('@vue-storefront/theme-', '') themePath = path.resolve(__dirname, '../../src/themes/' + themeName) - if(!fs.existsSync(themePath)) themePath = path.resolve(__dirname, '../../packages/theme-' + themeName) + if(!fs.existsSync(themePath)) { + console.error(` + The theme you want to use does not exist. + Please use 'vsf init' or install manualy one of our official themes: + - https://github.com/DivanteLtd/vsf-capybara#--installation + - https://github.com/DivanteLtd/vsf-default#--installation + `) + process.exit(1) + } } module.exports = themePath diff --git a/core/build/webpack.base.config.ts b/core/build/webpack.base.config.ts index 1cf00cb3e..a5002d33c 100644 --- a/core/build/webpack.base.config.ts +++ b/core/build/webpack.base.config.ts @@ -1,25 +1,19 @@ import { buildLocaleIgnorePattern } from './../i18n/helpers'; import path from 'path'; -import config from 'config'; import fs from 'fs'; import CaseSensitivePathsPlugin from 'case-sensitive-paths-webpack-plugin'; import VueLoaderPlugin from 'vue-loader/lib/plugin'; import autoprefixer from 'autoprefixer'; import HTMLPlugin from 'html-webpack-plugin'; -// const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; import webpack from 'webpack'; import dayjs from 'dayjs'; -fs.writeFileSync( - path.resolve(__dirname, './config.json'), - JSON.stringify(config) -) +// const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; // eslint-disable-next-line import/first import themeRoot from './theme-path'; const themesRoot = '../../src/themes' -const moduleRoot = path.resolve(__dirname, '../../src/modules') const themeResources = themeRoot + '/resource' const themeCSS = themeRoot + '/css' const themeApp = themeRoot + '/App.vue' @@ -28,23 +22,6 @@ const themedIndexMinimal = path.join(themeRoot, '/templates/index.minimal.templa const themedIndexBasic = path.join(themeRoot, '/templates/index.basic.template.html') const themedIndexAmp = path.join(themeRoot, '/templates/index.amp.template.html') -const csvDirectories = [ - path.resolve(__dirname, '../../node_modules/@vue-storefront/i18n/resource/i18n/') -] - -fs.readdirSync(moduleRoot).forEach(directory => { - const dirName = moduleRoot + '/' + directory + '/resource/i18n' - - if (fs.existsSync(dirName)) { - csvDirectories.push(dirName); - } -}); - -csvDirectories.push(path.resolve(__dirname, themeResources + '/i18n/')); - -const translationPreprocessor = require('@vue-storefront/i18n/scripts/translation.preprocessor.js') -translationPreprocessor(csvDirectories, config) - const postcssConfig = { loader: 'postcss-loader', options: { @@ -63,9 +40,9 @@ export default { plugins: [ new webpack.ContextReplacementPlugin(/dayjs[/\\]locale$/, buildLocaleIgnorePattern()), new webpack.ProgressPlugin(), - // new BundleAnalyzerPlugin({ - // generateStatsFile: true - // }), + /* new BundleAnalyzerPlugin({ + generateStatsFile: true + }), */ new CaseSensitivePathsPlugin(), new VueLoaderPlugin(), // generate output HTML diff --git a/core/build/webpack.client.config.ts b/core/build/webpack.client.config.ts index 6a3d71fea..553d7d8ec 100644 --- a/core/build/webpack.client.config.ts +++ b/core/build/webpack.client.config.ts @@ -9,9 +9,11 @@ const config = merge(base, { splitChunks: { cacheGroups: { commons: { - test: /[\\/]node_modules[\\/](vue|vuex|vue-router|vue-meta|vue-i18n|vuex-router-sync|localforage|lean-he|vue-lazyload|js-sha3|dayjs|core-js|whatwg-fetch|vuelidate)[\\/]/, + // create 'vendor' group from initial packages from node_modules + test: /node_modules/, name: 'vendor', - chunks: 'all' + chunks: 'initial', + priority: 1 } } }, diff --git a/core/compatibility/components/blocks/SidebarMenu/SidebarMenu.js b/core/compatibility/components/blocks/SidebarMenu/SidebarMenu.js index 5084b2c7a..a92416ec2 100644 --- a/core/compatibility/components/blocks/SidebarMenu/SidebarMenu.js +++ b/core/compatibility/components/blocks/SidebarMenu/SidebarMenu.js @@ -8,7 +8,10 @@ export default { name: 'SidebarMenu', mixins: [onEscapePress, CompareButton], computed: { - ...mapGetters('category', ['getCategories']), + ...mapGetters('category-next', ['getMenuCategories']), + getCategories () { + return this.getMenuCategories + }, categories () { return this.getCategories.filter((op) => { return op.level === (config.entities.category.categoriesDynamicPrefetchLevel >= 0 ? config.entities.category.categoriesDynamicPrefetchLevel : 2) // display only the root level (level =1 => Default Category), categoriesDynamicPrefetchLevel = 2 by default diff --git a/core/data-resolver/CartService.ts b/core/data-resolver/CartService.ts index 97b9b9333..770d2a729 100644 --- a/core/data-resolver/CartService.ts +++ b/core/data-resolver/CartService.ts @@ -1,3 +1,4 @@ +import getApiEndpointUrl from '@vue-storefront/core/helpers/getApiEndpointUrl'; import { DataResolver } from './types/DataResolver' import Task from '@vue-storefront/core/lib/sync/types/Task' import CartItem from '@vue-storefront/core/modules/cart/types/CartItem' @@ -7,7 +8,7 @@ import config from 'config'; const setShippingInfo = async (addressInformation: any): Promise => TaskQueue.execute({ - url: processLocalizedURLAddress(config.cart.shippinginfo_endpoint), + url: processLocalizedURLAddress(getApiEndpointUrl(config.cart, 'shippinginfo_endpoint')), payload: { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -19,7 +20,7 @@ const setShippingInfo = async (addressInformation: any): Promise => const getTotals = async (): Promise => TaskQueue.execute({ - url: processLocalizedURLAddress(config.cart.totals_endpoint), + url: processLocalizedURLAddress(getApiEndpointUrl(config.cart, 'totals_endpoint')), payload: { method: 'GET', headers: { 'Content-Type': 'application/json' }, @@ -30,8 +31,8 @@ const getTotals = async (): Promise => const getCartToken = async (guestCart: boolean = false, forceClientState: boolean = false): Promise => { const url = processLocalizedURLAddress(guestCart - ? config.cart.create_endpoint.replace('{{token}}', '') - : config.cart.create_endpoint) + ? getApiEndpointUrl(config.cart, 'create_endpoint').replace('{{token}}', '') + : getApiEndpointUrl(config.cart, 'create_endpoint')) return TaskQueue.execute({ url, @@ -47,7 +48,7 @@ const getCartToken = async (guestCart: boolean = false, forceClientState: boolea const updateItem = async (cartServerToken: string, cartItem: CartItem): Promise => TaskQueue.execute({ - url: processLocalizedURLAddress(config.cart.updateitem_endpoint), + url: processLocalizedURLAddress(getApiEndpointUrl(config.cart, 'updateitem_endpoint')), payload: { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -63,7 +64,7 @@ const updateItem = async (cartServerToken: string, cartItem: CartItem): Promise< const deleteItem = async (cartServerToken: string, cartItem: CartItem): Promise => TaskQueue.execute({ - url: processLocalizedURLAddress(config.cart.deleteitem_endpoint), + url: processLocalizedURLAddress(getApiEndpointUrl(config.cart, 'deleteitem_endpoint')), payload: { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -80,7 +81,7 @@ const deleteItem = async (cartServerToken: string, cartItem: CartItem): Promise< const getPaymentMethods = async (): Promise => TaskQueue.execute({ - url: processLocalizedURLAddress(config.cart.paymentmethods_endpoint), + url: processLocalizedURLAddress(getApiEndpointUrl(config.cart, 'paymentmethods_endpoint')), payload: { method: 'GET', headers: { 'Content-Type': 'application/json' }, @@ -91,7 +92,7 @@ const getPaymentMethods = async (): Promise => const getShippingMethods = async (address: any): Promise => TaskQueue.execute({ - url: processLocalizedURLAddress(config.cart.shippingmethods_endpoint), + url: processLocalizedURLAddress(getApiEndpointUrl(config.cart, 'shippingmethods_endpoint')), payload: { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -105,7 +106,7 @@ const getShippingMethods = async (address: any): Promise => const getItems = async (): Promise => TaskQueue.execute({ - url: processLocalizedURLAddress(config.cart.pull_endpoint), + url: processLocalizedURLAddress(getApiEndpointUrl(config.cart, 'pull_endpoint')), payload: { method: 'GET', headers: { 'Content-Type': 'application/json' }, @@ -115,7 +116,7 @@ const getItems = async (): Promise => }); const applyCoupon = async (couponCode: string): Promise => { - const url = processLocalizedURLAddress(config.cart.applycoupon_endpoint.replace('{{coupon}}', couponCode)) + const url = processLocalizedURLAddress(getApiEndpointUrl(config.cart, 'applycoupon_endpoint').replace('{{coupon}}', couponCode)) return TaskQueue.execute({ url, @@ -130,7 +131,7 @@ const applyCoupon = async (couponCode: string): Promise => { const removeCoupon = async (): Promise => TaskQueue.execute({ - url: processLocalizedURLAddress(config.cart.deletecoupon_endpoint), + url: processLocalizedURLAddress(getApiEndpointUrl(config.cart, 'deletecoupon_endpoint')), payload: { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/core/data-resolver/CategoryService.ts b/core/data-resolver/CategoryService.ts index c1146f488..1505f8eda 100644 --- a/core/data-resolver/CategoryService.ts +++ b/core/data-resolver/CategoryService.ts @@ -1,5 +1,5 @@ import { quickSearchByQuery } from '@vue-storefront/core/lib/search'; -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery'; +import { SearchQuery } from 'storefront-query-builder' import config from 'config'; import { DataResolver } from './types/DataResolver'; import { Category } from 'core/modules/catalog-next/types/Category'; diff --git a/core/data-resolver/NewsletterService.ts b/core/data-resolver/NewsletterService.ts index 4d1969dc9..7aaee8e32 100644 --- a/core/data-resolver/NewsletterService.ts +++ b/core/data-resolver/NewsletterService.ts @@ -2,10 +2,11 @@ import config from 'config'; import { DataResolver } from './types/DataResolver'; import { processURLAddress } from '@vue-storefront/core/helpers'; import { TaskQueue } from '@vue-storefront/core/lib/sync' +import getApiEndpointUrl from '@vue-storefront/core/helpers/getApiEndpointUrl'; const isSubscribed = (email: string): Promise => TaskQueue.execute({ - url: processURLAddress(config.newsletter.endpoint) + '?email=' + encodeURIComponent(email), + url: processURLAddress(getApiEndpointUrl(config.newsletter, 'endpoint')) + '?email=' + encodeURIComponent(email), payload: { method: 'GET', headers: { 'Content-Type': 'application/json' }, @@ -16,7 +17,7 @@ const isSubscribed = (email: string): Promise => const subscribe = (email: string): Promise => TaskQueue.execute({ - url: processURLAddress(config.newsletter.endpoint), + url: processURLAddress(getApiEndpointUrl(config.newsletter, 'endpoint')), payload: { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -27,7 +28,7 @@ const subscribe = (email: string): Promise => const unsubscribe = (email: string): Promise => TaskQueue.execute({ - url: processURLAddress(config.newsletter.endpoint), + url: processURLAddress(getApiEndpointUrl(config.newsletter, 'endpoint')), payload: { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, diff --git a/core/data-resolver/OrderService.ts b/core/data-resolver/OrderService.ts index c77bc73f6..96054296e 100644 --- a/core/data-resolver/OrderService.ts +++ b/core/data-resolver/OrderService.ts @@ -3,9 +3,10 @@ import { DataResolver } from './types/DataResolver'; import { Order } from '@vue-storefront/core/modules/order/types/Order' import { TaskQueue } from '@vue-storefront/core/lib/sync' import Task from '@vue-storefront/core/lib/sync/types/Task' +import getApiEndpointUrl from '@vue-storefront/core/helpers/getApiEndpointUrl'; const placeOrder = (order: Order): Promise => - TaskQueue.execute({ url: config.orders.endpoint, // sync the order + TaskQueue.execute({ url: getApiEndpointUrl(config.orders, 'endpoint'), // sync the order payload: { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/core/data-resolver/ProductService.ts b/core/data-resolver/ProductService.ts new file mode 100644 index 000000000..2b839e8fc --- /dev/null +++ b/core/data-resolver/ProductService.ts @@ -0,0 +1,196 @@ +import { getOptimizedFields } from '@vue-storefront/core/modules/catalog/helpers/search'; +import { canCache, storeProductToCache } from './../modules/catalog/helpers/search'; +import { doPlatformPricesSync } from '@vue-storefront/core/modules/catalog/helpers'; +import { isServer } from '@vue-storefront/core/helpers'; +import { quickSearchByQuery, isOnline } from '@vue-storefront/core/lib/search'; +import { SearchQuery } from 'storefront-query-builder' +import config from 'config'; +import { DataResolver } from './types/DataResolver'; +import { currentStoreView } from '@vue-storefront/core/lib/multistore' +import getApiEndpointUrl from '@vue-storefront/core/helpers/getApiEndpointUrl'; +import { TaskQueue } from '@vue-storefront/core/lib/sync' +import { entityKeyName } from '@vue-storefront/core/lib/store/entities' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' +import { Logger } from '@vue-storefront/core/lib/logger'; +import Product from '@vue-storefront/core/modules/catalog/types/Product'; +import { prepareProducts } from '@vue-storefront/core/modules/catalog/helpers/prepare'; +import { configureProducts } from '@vue-storefront/core/modules/catalog/helpers/configure'; + +const getProducts = async ({ + query, + start = 0, + size = 50, + sort = '', + excludeFields = null, + includeFields = null, + configuration = null, + options: { + prefetchGroupProducts = !isServer, + fallbackToDefaultWhenNoAvailable = true, + setProductErrors = false, + setConfigurableProductOptions = config.cart.setConfigurableProductOptions, + filterUnavailableVariants = config.products.filterUnavailableVariants, + assignProductConfiguration = false, + separateSelectedVariant = false + } = {} +}: DataResolver.ProductSearchOptions): Promise => { + const isCacheable = canCache({ includeFields, excludeFields }) + const { excluded, included } = getOptimizedFields({ excludeFields, includeFields }) + let { + items: products = [], + attributeMetadata = [], + aggregations = [], + total, + perPage + } = await quickSearchByQuery({ + query, + start, + size, + entityType: 'product', + sort, + excludeFields: excluded, + includeFields: included + }) + + products = prepareProducts(products) + + for (let product of products) { // we store each product separately in cache to have offline access to products/single method + if (isCacheable) { // store cache only for full loads + storeProductToCache(product, 'sku') + } + } + + const configuredProducts = await configureProducts({ + products, + attributes_metadata: attributeMetadata, + configuration, + options: { + prefetchGroupProducts, + fallbackToDefaultWhenNoAvailable, + setProductErrors, + setConfigurableProductOptions, + filterUnavailableVariants, + assignProductConfiguration, + separateSelectedVariant + }, + excludeFields: excluded, + includeFields: included + }) + + return { + items: configuredProducts, + perPage, + start, + total, + aggregations, + attributeMetadata + } +} + +const getProductRenderList = async ({ + skus, + isUserGroupedTaxActive, + userGroupId, + token +}): Promise => { + const { i18n, storeId } = currentStoreView() + let url = [ + `${getApiEndpointUrl(config.products, 'endpoint')}/render-list`, + `?skus=${encodeURIComponent(skus.join(','))}`, + `¤cyCode=${encodeURIComponent(i18n.currencyCode)}`, + `&storeId=${encodeURIComponent(storeId)}` + ].join('') + if (isUserGroupedTaxActive) { + url = `${url}&userGroupId=${userGroupId}` + } + + if (token) { + url = `${url}&token=${token}` + } + + try { + const task = await TaskQueue.execute({ url, // sync the cart + payload: { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + mode: 'cors' + }, + callback_event: 'prices-after-sync' + }) + return task.result as DataResolver.ProductsListResponse + } catch (err) { + console.error(err) + return { items: [] } + } +} + +const getProduct = async (options: { [key: string]: string }, key: string): Promise => { + let searchQuery = new SearchQuery() + searchQuery = searchQuery.applyFilter({ key: key, value: { 'eq': options[key] } }) + const { items = [] } = await getProducts({ + query: searchQuery, + size: 1, + configuration: { sku: options.childSku }, + options: { + prefetchGroupProducts: true, + assignProductConfiguration: true + } + }) + return items[0] || null +} + +const getProductFromCache = async (options: { [key: string]: string }, key: string): Promise => { + try { + const cacheKey = entityKeyName(key, options[key]) + const cache = StorageManager.get('elasticCache') + const result = await cache.getItem(cacheKey) + if (result !== null) { + if (config.products.alwaysSyncPlatformPricesOver) { + if (!config.products.waitForPlatformSync) { + await doPlatformPricesSync([result]) + } else { + doPlatformPricesSync([result]) + } + } + const { excluded, included } = getOptimizedFields({ excludeFields: null, includeFields: null }) + const [product] = await configureProducts({ + products: [result], + attributes_metadata: [], + configuration: { [key]: options.childSku || options.sku || options[key] }, + options: { + prefetchGroupProducts: true, + setConfigurableProductOptions: config.cart.setConfigurableProductOptions, + filterUnavailableVariants: config.products.filterUnavailableVariants, + assignProductConfiguration: true + }, + excludeFields: excluded, + includeFields: included + }) + return product + } else { + return getProduct(options, key) + } + } catch (err) { + // report errors + if (err) { + Logger.error(err, 'product')() + } + return getProduct(options, key) + } +} + +const getProductByKey = async ({ options, key, skipCache }: DataResolver.ProductByKeySearchOptions): Promise => { + if (!isOnline()) { + return getProductFromCache(options, key) + } + const result = skipCache + ? await getProduct(options, key) + : await getProductFromCache(options, key) + return result +} + +export const ProductService: DataResolver.ProductService = { + getProducts, + getProductRenderList, + getProductByKey +} diff --git a/core/data-resolver/ReviewsService.ts b/core/data-resolver/ReviewsService.ts index 4cfcaeb0a..da3fa3e88 100644 --- a/core/data-resolver/ReviewsService.ts +++ b/core/data-resolver/ReviewsService.ts @@ -3,10 +3,11 @@ import { TaskQueue } from '@vue-storefront/core/lib/sync' import { processLocalizedURLAddress } from '@vue-storefront/core/helpers' import config from 'config' import Review from 'core/modules/review/types/Review'; +import getApiEndpointUrl from '@vue-storefront/core/helpers/getApiEndpointUrl'; const createReview = (review: Review): Promise => TaskQueue.execute({ - url: processLocalizedURLAddress(config.reviews.create_endpoint), + url: processLocalizedURLAddress(getApiEndpointUrl(config.reviews, 'create_endpoint')), payload: { method: 'POST', mode: 'cors', diff --git a/core/data-resolver/StockService.ts b/core/data-resolver/StockService.ts index fa88961d0..7c89bc596 100644 --- a/core/data-resolver/StockService.ts +++ b/core/data-resolver/StockService.ts @@ -3,10 +3,11 @@ import { DataResolver } from './types/DataResolver'; import { TaskQueue } from '@vue-storefront/core/lib/sync'; import Task from '@vue-storefront/core/lib/sync/types/Task'; import { processURLAddress } from '@vue-storefront/core/helpers'; +import getApiEndpointUrl from '@vue-storefront/core/helpers/getApiEndpointUrl'; const queueCheck = (sku: string, actionName: string): Promise => TaskQueue.queue({ - url: processURLAddress(`${config.stock.endpoint}/check?sku=${encodeURIComponent(sku)}`), + url: processURLAddress(`${getApiEndpointUrl(config.stock, 'endpoint')}/check?sku=${encodeURIComponent(sku)}`), payload: { method: 'GET', headers: { 'Content-Type': 'application/json' }, @@ -19,7 +20,7 @@ const queueCheck = (sku: string, actionName: string): Promise => const check = (sku: string): Promise => TaskQueue.execute({ - url: processURLAddress(`${config.stock.endpoint}/check?sku=${encodeURIComponent(sku)}`), + url: processURLAddress(`${getApiEndpointUrl(config.stock, 'endpoint')}/check?sku=${encodeURIComponent(sku)}`), payload: { method: 'GET', headers: { 'Content-Type': 'application/json' }, @@ -32,7 +33,7 @@ const check = (sku: string): Promise => const list = (skuList: string[]): Promise => TaskQueue.execute({ url: processURLAddress( - `${config.stock.endpoint}/list?skus=${encodeURIComponent( + `${getApiEndpointUrl(config.stock, 'endpoint')}/list?skus=${encodeURIComponent( skuList.join(',') )}` ), diff --git a/core/data-resolver/UserService.ts b/core/data-resolver/UserService.ts index afbf9691a..97ad0aa49 100644 --- a/core/data-resolver/UserService.ts +++ b/core/data-resolver/UserService.ts @@ -4,6 +4,7 @@ import { TaskQueue } from '@vue-storefront/core/lib/sync' import Task from '@vue-storefront/core/lib/sync/types/Task' import { processLocalizedURLAddress } from '@vue-storefront/core/helpers' import config from 'config' +import getApiEndpointUrl from '@vue-storefront/core/helpers/getApiEndpointUrl'; const headers = { 'Accept': 'application/json, text/plain, */*', @@ -12,7 +13,7 @@ const headers = { const resetPassword = async (email: string): Promise => TaskQueue.execute({ - url: processLocalizedURLAddress(config.users.resetPassword_endpoint), + url: processLocalizedURLAddress(getApiEndpointUrl(config.users, 'resetPassword_endpoint')), payload: { method: 'POST', mode: 'cors', @@ -21,9 +22,20 @@ const resetPassword = async (email: string): Promise => } }) +const createPassword = async (email: string, newPassword: string, resetToken: string): Promise => + TaskQueue.execute({ + url: processLocalizedURLAddress(config.users.createPassword_endpoint), + payload: { + method: 'POST', + mode: 'cors', + headers, + body: JSON.stringify({ email, newPassword, resetToken }) + } + }) + const login = async (username: string, password: string): Promise => TaskQueue.execute({ - url: processLocalizedURLAddress(config.users.login_endpoint), + url: processLocalizedURLAddress(getApiEndpointUrl(config.users, 'login_endpoint')), payload: { method: 'POST', mode: 'cors', @@ -34,7 +46,7 @@ const login = async (username: string, password: string): Promise => const register = async (customer: DataResolver.Customer, password: string): Promise => TaskQueue.execute({ - url: processLocalizedURLAddress(config.users.create_endpoint), + url: processLocalizedURLAddress(getApiEndpointUrl(config.users, 'create_endpoint')), payload: { method: 'POST', headers, @@ -44,7 +56,7 @@ const register = async (customer: DataResolver.Customer, password: string): Prom const updateProfile = async (userProfile: UserProfile, actionName: string): Promise => TaskQueue.queue({ - url: processLocalizedURLAddress(config.users.me_endpoint), + url: processLocalizedURLAddress(getApiEndpointUrl(config.users, 'me_endpoint')), payload: { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -56,7 +68,7 @@ const updateProfile = async (userProfile: UserProfile, actionName: string): Prom const getProfile = async () => TaskQueue.execute({ - url: processLocalizedURLAddress(config.users.me_endpoint), + url: processLocalizedURLAddress(getApiEndpointUrl(config.users, 'me_endpoint')), payload: { method: 'GET', mode: 'cors', @@ -67,7 +79,7 @@ const getProfile = async () => const getOrdersHistory = async (pageSize = 20, currentPage = 1): Promise => TaskQueue.execute({ url: processLocalizedURLAddress( - config.users.history_endpoint.replace('{{pageSize}}', pageSize).replace('{{currentPage}}', currentPage) + getApiEndpointUrl(config.users, 'history_endpoint').replace('{{pageSize}}', pageSize + '').replace('{{currentPage}}', currentPage + '') ), payload: { method: 'GET', @@ -78,7 +90,7 @@ const getOrdersHistory = async (pageSize = 20, currentPage = 1): Promise = const changePassword = async (passwordData: DataResolver.PasswordData): Promise => TaskQueue.execute({ - url: processLocalizedURLAddress(config.users.changePassword_endpoint), + url: processLocalizedURLAddress(getApiEndpointUrl(config.users, 'changePassword_endpoint')), payload: { method: 'POST', mode: 'cors', @@ -88,7 +100,7 @@ const changePassword = async (passwordData: DataResolver.PasswordData): Promise< }) const refreshToken = async (refreshToken: string): Promise => - fetch(processLocalizedURLAddress(config.users.refresh_endpoint), { + fetch(processLocalizedURLAddress(getApiEndpointUrl(config.users, 'refresh_endpoint')), { method: 'POST', mode: 'cors', headers, @@ -98,6 +110,7 @@ const refreshToken = async (refreshToken: string): Promise => export const UserService: DataResolver.UserService = { resetPassword, + createPassword, login, register, updateProfile, diff --git a/core/data-resolver/types/DataResolver.d.ts b/core/data-resolver/types/DataResolver.d.ts index 40ff960c8..88631409d 100644 --- a/core/data-resolver/types/DataResolver.d.ts +++ b/core/data-resolver/types/DataResolver.d.ts @@ -1,9 +1,12 @@ -import { Category } from 'core/modules/catalog-next/types/Category'; -import { UserProfile } from 'core/modules/user/types/UserProfile' +import { AttributesMetadata } from '@vue-storefront/core/modules/catalog/types/Attribute'; +import { Category } from '@vue-storefront/core/modules/catalog-next/types/Category'; +import { UserProfile } from '@vue-storefront/core/modules/user/types/UserProfile' import CartItem from '@vue-storefront/core/modules/cart/types/CartItem' import { Order } from '@vue-storefront/core/modules/order/types/Order' import Task from '@vue-storefront/core/lib/sync/types/Task' -import Review from 'core/modules/review/types/Review'; +import Review from '@vue-storefront/core/modules/review/types/Review'; +import { SearchQuery } from 'storefront-query-builder'; +import Product from '@vue-storefront/core/modules/catalog/types/Product'; declare namespace DataResolver { @@ -21,6 +24,38 @@ declare namespace DataResolver { reloadAll?: boolean } + interface ProductSearchOptions { + query: SearchQuery, + size?: number, + start?: number, + sort?: string, + includeFields?: string[], + excludeFields?: string[], + configuration?: { [key: string]: string[] | string }, + options?: { + prefetchGroupProducts?: boolean, + fallbackToDefaultWhenNoAvailable?: boolean, + setProductErrors?: boolean, + setConfigurableProductOptions?: boolean, + filterUnavailableVariants?: boolean, + assignProductConfiguration?: boolean, + separateSelectedVariant?: boolean + } + } + + interface ProductRenderListSearchOptions { + skus: string[], + isUserGroupedTaxActive?: boolean, + userGroupId?: string, + token?: string + } + + interface ProductByKeySearchOptions { + options: { [key: string]: string }, + key?: string, + skipCache?: boolean + } + interface Customer { email: string, firstname: string, @@ -33,12 +68,28 @@ declare namespace DataResolver { newPassword: string } + interface ProductsListResponse { + items: Product[], + perPage?: number, + start?: number, + total?: number, + aggregations?: any[], + attributeMetadata?: AttributesMetadata[] + } + + interface ProductService { + getProducts: (searchRequest: ProductSearchOptions) => Promise, + getProductRenderList: (searchRequest: ProductRenderListSearchOptions) => Promise, + getProductByKey: (searchRequest: ProductByKeySearchOptions) => Promise + } + interface CategoryService { getCategories: (searchRequest?: CategorySearchOptions) => Promise } interface UserService { resetPassword: (email: string) => Promise, + createPassword: (email: string, newPassword: string, resetToken: string) => Promise, login: (username: string, password: string) => Promise, register: (customer: Customer, pssword: string) => Promise, updateProfile: (userProfile: UserProfile, actionName: string) => Promise, diff --git a/core/filters/price.js b/core/filters/price.js index a7e6d858a..b17c204cf 100644 --- a/core/filters/price.js +++ b/core/filters/price.js @@ -1,13 +1,21 @@ -import { currentStoreView } from '@vue-storefront/core/lib/multistore'; - -const formatValue = (value, locale) => { - const price = Math.abs(parseFloat(value)); - return price.toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); -}; +import { currentStoreView } from '@vue-storefront/core/lib/multistore' const applyCurrencySign = (formattedPrice, { currencySign, priceFormat }) => { return priceFormat.replace('{sign}', currencySign).replace('{amount}', formattedPrice) -} +}; + +const getLocaleSeparators = (defaultLocale) => { + return { + decimal: (0.01).toLocaleString(defaultLocale).replace(/[0-9]/g, ''), + group: (1000).toLocaleString(defaultLocale).replace(/[0-9]/g, '') + } +}; + +const replaceSeparators = (formattedPrice, currencySeparators, separators) => { + if (currencySeparators.decimal) formattedPrice = formattedPrice.replace(separators.decimal, currencySeparators.decimal); + if (currencySeparators.group) formattedPrice = formattedPrice.replace(separators.group, currencySeparators.group); + return formattedPrice; +}; /** * Converts number to price string @@ -15,20 +23,24 @@ const applyCurrencySign = (formattedPrice, { currencySign, priceFormat }) => { */ export function price (value, storeView) { if (isNaN(value)) { - return value; + return value } const _storeView = storeView || currentStoreView(); if (!_storeView.i18n) { - return value; + return Number(value).toFixed(2) } - const { defaultLocale, currencySign, priceFormat } = _storeView.i18n - const formattedValue = formatValue(value, defaultLocale); - const valueWithSign = applyCurrencySign(formattedValue, { currencySign, priceFormat }) + const { defaultLocale, currencySign, currencyDecimal, currencyGroup, fractionDigits, priceFormat } = _storeView.i18n; + + const options = { minimumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits }; - if (value >= 0) { - return valueWithSign; - } else { - return '-' + valueWithSign; + let localePrice = Math.abs(Number(value).toLocaleString(defaultLocale, options)).toFixed(2); + + if (currencyDecimal !== '' || currencyGroup !== '') { + localePrice = replaceSeparators(localePrice, { decimal: currencyDecimal, group: currencyGroup }, getLocaleSeparators(defaultLocale)); } + + const valueWithSign = applyCurrencySign(localePrice, { currencySign, priceFormat }); + + return value >= 0 ? valueWithSign : '-' + valueWithSign; } diff --git a/core/helpers/getApiEndpointUrl.ts b/core/helpers/getApiEndpointUrl.ts new file mode 100644 index 000000000..86b5a4f0c --- /dev/null +++ b/core/helpers/getApiEndpointUrl.ts @@ -0,0 +1,11 @@ +import { isServer } from '@vue-storefront/core/helpers'; + +// object - parent object in the config, e.g. config.cart +// field - field inside the object, e.g. create_endpoint + +// returns - object.[field]_ssr if it exists and it is a server, +// object.field otherwise + +export default (object: Record, field: string): string => { + return isServer && object[`${field}_ssr`] ? object[`${field}_ssr`] : object[field] +} diff --git a/core/helpers/index.ts b/core/helpers/index.ts index 10df4ce9d..7cd2e1ede 100644 --- a/core/helpers/index.ts +++ b/core/helpers/index.ts @@ -1,4 +1,4 @@ -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' +import { SearchQuery } from 'storefront-query-builder' import { remove as removeAccents } from 'remove-accents' import { formatCategoryLink } from '@vue-storefront/core/modules/url/helpers' import Vue from 'vue' @@ -7,10 +7,11 @@ import { sha3_224 } from 'js-sha3' import store from '@vue-storefront/core/store' import { adjustMultistoreApiUrl } from '@vue-storefront/core/lib/multistore' import { coreHooksExecutors } from '@vue-storefront/core/hooks'; +import getApiEndpointUrl from '@vue-storefront/core/helpers/getApiEndpointUrl'; import omit from 'lodash-es/omit' export const processURLAddress = (url: string = '') => { - if (url.startsWith('/')) return `${config.api.url}${url}` + if (url.startsWith('/')) return `${getApiEndpointUrl(config.api, 'url')}${url}` return url } @@ -47,14 +48,14 @@ export function slugify (text) { * @returns {string} */ export function getThumbnailPath (relativeUrl: string, width: number = 0, height: number = 0, pathType: string = 'product'): string { + if (config.images.useSpecificImagePaths) { + const path = config.images.paths[pathType] !== undefined ? config.images.paths[pathType] : '' + relativeUrl = path + relativeUrl + } + if (config.images.useExactUrlsNoProxy) { - return coreHooksExecutors.afterProductThumbnailPathGenerate({ path: relativeUrl, sizeX: width, sizeY: height }).path // this is exact url mode + return coreHooksExecutors.afterProductThumbnailPathGenerate({ path: relativeUrl, sizeX: width, sizeY: height, pathType }).path // this is exact url mode } else { - if (config.images.useSpecificImagePaths) { - const path = config.images.paths[pathType] !== undefined ? config.images.paths[pathType] : '' - relativeUrl = path + relativeUrl - } - let resultUrl if (relativeUrl && (relativeUrl.indexOf('://') > 0 || relativeUrl.indexOf('?') > 0 || relativeUrl.indexOf('&') > 0)) relativeUrl = encodeURIComponent(relativeUrl) // proxyUrl is not a url base path but contains {{url}} parameters and so on to use the relativeUrl as a template value and then do the image proxy opertions @@ -69,7 +70,7 @@ export function getThumbnailPath (relativeUrl: string, width: number = 0, height } const path = relativeUrl && relativeUrl.indexOf('no_selection') < 0 ? resultUrl : config.images.productPlaceholder || '' - return coreHooksExecutors.afterProductThumbnailPathGenerate({ path, sizeX: width, sizeY: height }).path + return coreHooksExecutors.afterProductThumbnailPathGenerate({ path, sizeX: width, sizeY: height, pathType }).path } } @@ -261,3 +262,97 @@ export function extendStore (moduleName: string | string[], module: any) { store.unregisterModule(moduleName) store.registerModule(moduleName, extendedModule) } + +export function reviewJsonLd (reviews, { name, category, mpn, url_path, price, stock, is_in_stock, sku, image, description }, priceCurrency) { + return reviews.map(({ title, detail, nickname, created_at }) => ( + { + '@context': 'http://schema.org/', + '@type': 'Review', + reviewAspect: title, + reviewBody: detail, + datePublished: created_at, + author: nickname, + itemReviewed: { + '@type': 'Product', + name, + sku, + image, + description, + offers: { + '@type': 'Offer', + category: category + ? category + .map(({ name }) => name || null) + .filter(name => name !== null) + : null, + mpn, + url: url_path, + priceCurrency, + price, + itemCondition: 'https://schema.org/NewCondition', + availability: stock && is_in_stock ? 'InStock' : 'OutOfStock' + } + } + } + ) + ) +} + +function getMaterials (material, customAttributes) { + const materialsArr = [] + if (customAttributes && customAttributes.length && customAttributes.length > 0 && material && material.length && material.length > 0) { + const materialOptions = customAttributes.find(({ attribute_code }) => attribute_code === 'material').options + if (Array.isArray(material)) { + for (let key in materialOptions) { + material.forEach(el => { + if (String(el) === materialOptions[key].value) { + materialsArr.push(materialOptions[key].label) + } + }) + } + } else { + for (let key in materialOptions) { + if (material === materialOptions[key].value) { + materialsArr.push(materialOptions[key].label) + } + } + } + } + return materialsArr +} + +export function productJsonLd ({ category, image, name, id, sku, mpn, description, price, url_path, stock, is_in_stock, material }, color, priceCurrency, customAttributes) { + return { + '@context': 'http://schema.org', + '@type': 'Product', + category: category + ? category + .map(({ name }) => name || null) + .filter(name => name !== null) + : null, + color, + description, + image, + itemCondition: 'http://schema.org/NewCondition', + material: getMaterials(material, customAttributes), + name, + productID: id, + sku, + mpn, + offers: { + '@type': 'Offer', + category: category + ? category + .map(({ name }) => name || null) + .filter(name => name !== null) + : null, + mpn, + url: url_path, + priceCurrency, + price, + itemCondition: 'https://schema.org/NewCondition', + availability: stock && is_in_stock ? 'InStock' : 'OutOfStock', + sku + } + } +} diff --git a/core/hooks.ts b/core/hooks.ts index 8e0c08368..8d4152b2b 100644 --- a/core/hooks.ts +++ b/core/hooks.ts @@ -23,7 +23,12 @@ const { const { hook: afterProductThumbnailPathGeneratedHook, executor: afterProductThumbnailPathGeneratedExecutor -} = createMutatorHook<{ path: string, sizeX: number, sizeY: number }, { path: string }>() +} = createMutatorHook<{ path: string, pathType: string, sizeX: number, sizeY: number }, { path: string }>() + +const { + hook: beforeLogRenderedHook, + executor: beforeLogRenderedExecutor +} = createMutatorHook<{ type: string, message: any, tag: any, context: any, noDefaultOutput?: boolean }, { message: any, tag: any, context: any, noDefaultOutput?: boolean }>() /** Only for internal usage in core */ const coreHooksExecutors = { @@ -31,7 +36,8 @@ const coreHooksExecutors = { beforeStoreViewChanged: beforeStoreViewChangedExecutor, afterStoreViewChanged: afterStoreViewChangedExecutor, beforeHydrated: beforeHydratedExecutor, - afterProductThumbnailPathGenerate: afterProductThumbnailPathGeneratedExecutor + afterProductThumbnailPathGenerate: afterProductThumbnailPathGeneratedExecutor, + beforeLogRendered: beforeLogRenderedExecutor } const coreHooks = { @@ -46,7 +52,8 @@ const coreHooks = { */ afterStoreViewChanged: afterStoreViewChangedHook, beforeHydrated: beforeHydratedHook, - afterProductThumbnailPathGenerate: afterProductThumbnailPathGeneratedHook + afterProductThumbnailPathGenerate: afterProductThumbnailPathGeneratedHook, + beforeLogRendered: beforeLogRenderedHook } export { diff --git a/core/i18n/intl.ts b/core/i18n/intl.ts new file mode 100644 index 000000000..05a548f95 --- /dev/null +++ b/core/i18n/intl.ts @@ -0,0 +1,13 @@ +import areIntlLocalesSupported from 'intl-locales-supported' + +export const importIntlPolyfill = async () => { + const IntlPolyfill = await import('intl') + global.Intl = IntlPolyfill.default +} + +export const checkForIntlPolyfill = async (storeView) => { + const globDTO = typeof window !== 'undefined' ? window : global + if (!globDTO.hasOwnProperty('Intl') || !areIntlLocalesSupported(storeView.i18n.defaultLocale)) { + await importIntlPolyfill() + } +} diff --git a/core/i18n/package.json b/core/i18n/package.json index 2f260f8c4..290d0f99c 100644 --- a/core/i18n/package.json +++ b/core/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@vue-storefront/i18n", - "version": "1.11.4", + "version": "1.12.0", "description": "Vue Storefront i18n", "license": "MIT", "main": "index.ts", diff --git a/core/i18n/resource/i18n/ar-SA.csv b/core/i18n/resource/i18n/ar-SA.csv new file mode 100644 index 000000000..03997aa6f --- /dev/null +++ b/core/i18n/resource/i18n/ar-SA.csv @@ -0,0 +1,77 @@ +Registering the account ...,جاري تسجيل الحساب +No products synchronized for this category. Please come back while online!,.لا يوجد منتجات تمت مزامنتها لهذا التصنيف. يرجى العودة مرة أخرى إذا تم الاتصال بالإنترنت +Shopping cart is empty. Please add some products before entering Checkout,سلة التسوق فارغة. يرجى إضافة بعض المنتجات للانتقال إلى صفحة إتمام الطلب +Out of stock!,غير متوفر! + is out of the stock!,غير متوفر بالمخزون! +Some of the ordered products are not available!,بعض المنتجات المطلوبة غير متاحة +Please wait ...,انتظر من فضلك… +"Stock check in progress, please wait while available stock quantities are checked",جاري فحص المخزون، يرجى الانتظار حتى يتم التأكد من التوفر +There is no Internet connection. You can still place your order. We will notify you if any of ordered products is not available because we cannot check it right now.,لا يوجد اتصال بالإنترنت. مازال بإمكانك إتمام الطلب. وسنقوم بإخطارك إذا كان أي من المنتجات المطلوبة غير متوفر، لايمكننا التحقق الآن. +No such configuration for the product. Please do choose another combination of attributes.,لا يوجد خيارات منطبقة لهذا المنتج من فضلك قم بتعديل الخيارات. +The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.,الكمية غير مؤكدة (volatile). تمت إضافة المنتج للسلة للحجز المسبق. +This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!,This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap! +The product is out of stock and cannot be added to the cart!,المنتج غير متوفر بالمخزون ولا يمكن إضافته للسلة +Product has been added to the cart!,تمت إضافة المنتج للسلة! +Product quantity has been updated!,تم تحديث الكمية! +Internal validation error. Please check if all required fields are filled in. Please contact us on {email},خطأ تحقق داخلي. يرجى التأكد من تعبئة جميع الحقول المطلوبة. فضلاً تواصل معنا على {email} +Address provided in checkout contains invalid data. Please check if all required fields are filled in and also contact us on {email} to resolve this issue for future. Your order has been canceled.,العنوان المسجل في إنهاء الطلب يحتوي معلومات غير صالحة. يرجى التأكد من تعبئة جميع الحقول المطلوبة أيضاً يمكنك التواص معنا عبر {email} للمساعدة. تم إلغاء الطلب. +Product {productName} has been added to the compare!,تمت إضافة المنتج {productName} إلى المقارنة! +Product {productName} has been removed from compare!,تمت إزالة المنتج {productName} من المقارنة! +Product {productName} has been added to wishlist!,تمت إضافة المنتج {productName} إلى قائمة الأمنيات بنجاح! +Product {productName} has been removed from wishlit!,تمت إزالة المنتج {productName} من قائمة الأمنيات بنجاح! +Account data has successfully been updated,تم تحديث الحساب بنجاح +Newsletter preferences have successfully been updated,تم تحديث تفضيلات النشرة البريدية +Reset password feature does not work while offline!,لا يمكن إستعادة كلمة المرور بدون اتصال بالشبكة! +You are logged in!,تم تسجيل دخولك! +Please fix the validation errors,يرجى إصلاح خطأ التحقق +"Product price is unknown, product cannot be added to the cart!",سعر المنتج غير معلوم، لايمكن إضافته للسلة. +My Account,حسابي +Type what you are looking for...,اكتب ما تبحث عنه… +Home Page,الصفحة الرئيسية +Checkout,إتمام الطلب +Subtotal incl. tax,المجموع متضمن الضريبة +Grand total,المجموع النهائي +Field is required,حقل مطلوب +Field is required.,حقل مطلوب. +You're logged out,تم تسجيل خروجك +Compare Products,مقارنة المنتجات +404 Page Not Found,404 الصفحة غير متوفرة +Error with response - bad content-type!,خطأ بالرد - محتوى غير صالح! +"Unhandled error, wrong response format!","Unhandled error, wrong response format!" +not authorized,غير مصرح +Internal Application error while refreshing the tokens. Please clear the storage and refresh page.,Internal Application error while refreshing the tokens. Please clear the storage and refresh page. +Proceed to checkout,إتمام الطلب +OK,موافق +Out of the stock!,غير متوفر بالمخزون! +In stock!,متوفر! +Please configure product custom options and fix the validation errors,من فضلك حدد خيارات المنتج لإصلاح خطأ التحقق +Error refreshing user token. User is not authorized to access the resource,خطأ في تحديث التشفير. المستخدم غير مصرح له بالدخول +Must be greater than 0,يجب أن يكون أكثر من 0 +Please select the field which You like to sort by,اختر حقل للترتيب به +No available product variants,لا يوجد منتجات متوفرة +email,البريد الإلكتروني +password,كلمة المرور +Confirm your order,أكّد طلبك +Please confirm order you placed when you was offline,فضلاً أكد الطلب الذي أنشأته بدون اتصال إنترنت +Payment Information,معلومات الدفع +You are to pay for this order upon delivery.,ستقوم بالدفع لهذا الطلب عند الاستلام. +Allow notification about the order,السماح بتنبيهات الطلب +Extension developers would like to thank you for placing an order!,Extension developers would like to thank you for placing an order! +most you may purchase,most you may purchase +have as many,have as many +Compare products,مقارنة المنتجات +Reviews,مراجعات +Review,مراجعة +Add review,أضف مراجعة +Summary,ملخص +login,تسجيل الدخول +to account,إلى الحساب +Are you sure you would like to remove this item from the shopping cart?,هل أنت متأكد من إزالة هذا المنتج من سلة التسوق +"The product, category or CMS page is not available in Offline mode. Redirecting to Home.",المنتج أو التصنيف أو الصفحة غير متوفرة بدون اتصال إنترنت. جاري التحويل للصفحة الرئيسية. +Please configure product bundle options and fix the validation errors,يرجى تحديد الخيارات وإصلاح خطأ التحقق +Processing order...,جاري معالجة الطلب… +You need to be logged in to see this page,يجب تسجيل الدخول لعرض الصفحة +Quantity must be above 0,يجب أن تكون الكمية أكثر من 0 +Error: Error while adding products,خطأ: خطأ أثناء إضافة المنتجات +Unexpected authorization error. Check your Network conection.,خطأ تحقق غير متوقع. تحقق من اتصالك بالشبكة. +Columns,أعمدة diff --git a/core/i18n/scripts/translation.preprocessor.js b/core/i18n/scripts/translation.preprocessor.js index 985944c4c..03c92c6c3 100644 --- a/core/i18n/scripts/translation.preprocessor.js +++ b/core/i18n/scripts/translation.preprocessor.js @@ -28,13 +28,15 @@ module.exports = function (csvDirectories, config = null) { const extName = path.extname(fullFileName) const baseName = path.posix.basename(file, extName) - if (extName === '.csv') { - const fileContent = fs.readFileSync(fullFileName, 'utf8') - if (languages.indexOf(baseName) === -1) { - languages.push(baseName) + if (currentLocales.indexOf(baseName) !== -1) { + if (extName === '.csv') { + const fileContent = fs.readFileSync(fullFileName, 'utf8') + if (languages.indexOf(baseName) === -1) { + languages.push(baseName) + } + console.debug(`Processing translation file: ${fullFileName}`) + messages[baseName] = Object.assign(messages[baseName] ? messages[baseName] : {}, convertToObject(dsv.parseRows(fileContent))) } - console.debug(`Processing translation file: ${fullFileName}`) - messages[baseName] = Object.assign(messages[baseName] ? messages[baseName] : {}, convertToObject(dsv.parseRows(fileContent))) } }) }) diff --git a/core/lib/logger.ts b/core/lib/logger.ts index 588e7c987..9c0e3e931 100644 --- a/core/lib/logger.ts +++ b/core/lib/logger.ts @@ -1,4 +1,5 @@ import { isServer } from '@vue-storefront/core/helpers' +import { coreHooksExecutors } from '@vue-storefront/core/hooks' import buildTimeConfig from 'config' const bgColorStyle = (color) => `color: white; background: ${color}; padding: 4px; font-weight: bold; font-size: 0.8em'` @@ -71,6 +72,12 @@ class Logger { return () => {} } + let noDefaultOutput + ({ message, tag, context, noDefaultOutput } = coreHooksExecutors.beforeLogRendered({ type: 'debug', message, tag, context })) + if (noDefaultOutput === true) { + return () => {} + } + if (isServer) { return console.debug.bind(console, (tag ? `[${tag}] ` : '') + this.convertToString(message), context) } @@ -107,6 +114,12 @@ class Logger { return () => {} } + let noDefaultOutput + ({ message, tag, context, noDefaultOutput } = coreHooksExecutors.beforeLogRendered({ type: 'info', message, tag, context })) + if (noDefaultOutput === true) { + return () => {} + } + if (isServer) { return console.log.bind(console, (tag ? `[${tag}] ` : '') + this.convertToString(message), context) } @@ -130,6 +143,13 @@ class Logger { if (!this.canPrint('warn')) { return () => {} } + + let noDefaultOutput + ({ message, tag, context, noDefaultOutput } = coreHooksExecutors.beforeLogRendered({ type: 'warn', message, tag, context })) + if (noDefaultOutput === true) { + return () => {} + } + if (isServer) { return console.warn.bind(console, (tag ? `[${tag}] ` : '') + this.convertToString(message), context) } @@ -150,6 +170,12 @@ class Logger { * @param context meaningful data related to this message */ public error (message: any, tag: string = null, context: any = null): () => void { + let noDefaultOutput + ({ message, tag, context, noDefaultOutput } = coreHooksExecutors.beforeLogRendered({ type: 'error', message, tag, context })) + if (noDefaultOutput === true) { + return () => {} + } + if (isServer) { // always show errors in SSR return console.error.bind(console, (tag ? `[${tag}] ` : '') + this.convertToString(message), context) } diff --git a/core/lib/multistore.ts b/core/lib/multistore.ts index cd163375e..3b832b766 100644 --- a/core/lib/multistore.ts +++ b/core/lib/multistore.ts @@ -55,7 +55,6 @@ export function currentStoreView (): StoreView { export async function prepareStoreView (storeCode: string): Promise { let storeView: StoreView = buildBaseStoreView() // current, default store - if (config.storeViews.multistore === true) { storeView.storeCode = storeCode || config.defaultStoreCode || '' } else { @@ -83,6 +82,7 @@ export async function prepareStoreView (storeCode: string): Promise { initializeSyncTaskStorage() StorageManager.currentStoreCode = storeView.storeCode } + coreHooksExecutors.afterStoreViewChanged(storeView) return storeView @@ -128,7 +128,7 @@ export function adjustMultistoreApiUrl (url: string): string { return url } -export function localizedDispatcherRoute (routeObj: LocalizedRoute | string, storeCode: string): LocalizedRoute | string { +export function localizedDispatcherRoute (routeObj: LocalizedRoute | string, storeCode?: string): LocalizedRoute | string { const { storeCode: currentStoreCode, appendStoreCode } = currentStoreView() if (!storeCode || !config.storeViews[storeCode]) { storeCode = currentStoreCode diff --git a/core/lib/search.ts b/core/lib/search.ts index e0a5cacea..6dd580076 100644 --- a/core/lib/search.ts +++ b/core/lib/search.ts @@ -71,7 +71,7 @@ export const quickSearchByQuery = async ({ query = {}, start = 0, size = 50, ent return } } catch (err) { - console.error('Cannot read cache for ' + cacheKey + ', ' + err) + Logger.error('Cannot read cache for ' + cacheKey + ', ' + err)() } /* use only for cache */ @@ -91,7 +91,7 @@ export const quickSearchByQuery = async ({ query = {}, start = 0, size = 50, ent const res = searchAdapter.entities[Request.type].resultProcessor(resp, start, size) if (res) { // otherwise it can be just a offline mode - cache.setItem(cacheKey, res, null, config.elasticsearch.disablePersistentQueriesCache).catch((err) => { console.error('Cannot store cache for ' + cacheKey + ', ' + err) }) + cache.setItem(cacheKey, res, null, config.elasticsearch.disablePersistentQueriesCache).catch((err) => { Logger.error('Cannot store cache for ' + cacheKey + ', ' + err)() }) if (!servedFromCache) { // if navigator onLine == false means ES is unreachable and probably this will return false; sometimes returned false faster than indexedDb cache returns result ... Logger.debug('Result from ES for ' + cacheKey + ' (' + entityType + '), ms=' + (new Date().getTime() - benchmarkTime.getTime()))() res.cache = false diff --git a/core/lib/search/adapter/api-search-query/searchAdapter.ts b/core/lib/search/adapter/api-search-query/searchAdapter.ts new file mode 100644 index 000000000..5357c2571 --- /dev/null +++ b/core/lib/search/adapter/api-search-query/searchAdapter.ts @@ -0,0 +1,160 @@ +import map from 'lodash-es/map' +import fetch from 'isomorphic-fetch' +import { slugify, processURLAddress } from '@vue-storefront/core/helpers' +import queryString from 'query-string' +import { currentStoreView, prepareStoreView } from '@vue-storefront/core/lib/multistore' +import { SearchQuery } from 'storefront-query-builder' +import HttpQuery from '@vue-storefront/core/types/search/HttpQuery' +import { SearchResponse } from '@vue-storefront/core/types/search/SearchResponse' +import config from 'config' +import getApiEndpointUrl from '@vue-storefront/core/helpers/getApiEndpointUrl'; + +export class SearchAdapter { + public entities: any + + public constructor () { + this.entities = [] + this.initBaseTypes() + } + + protected decompactItem (item, fieldsToCompact) { + for (let key in fieldsToCompact) { + const value = fieldsToCompact[key] + if (typeof item[value] !== 'undefined') { + item[key] = item[value] + delete item[value] + } + } + return item + } + + public async search (Request) { + const rawQueryObject = Request.searchQuery + if (!this.entities[Request.type]) { + throw new Error('No entity type registered for ' + Request.type) + } + if (!(Request.searchQuery instanceof SearchQuery)) { + throw new Error('The only supported type of the "Request.searchQuery" is "SearchQuery"') + } + if (Request.hasOwnProperty('groupId') && Request.groupId !== null) { + rawQueryObject['groupId'] = Request.groupId + } + if (Request.hasOwnProperty('groupToken') && Request.groupToken !== null) { + rawQueryObject['groupToken'] = Request.groupToken + } + if (Request.sort) { + const [ field, options ] = Request.sort.split(':') + rawQueryObject.applySort({ field, options }) + delete Request.sort + } + const storeView = (Request.store === null) ? currentStoreView() : await prepareStoreView(Request.store) + Request.index = storeView.elasticsearch.index + + let url = processURLAddress(getApiEndpointUrl(storeView.elasticsearch, 'host')) + + if (this.entities[Request.type].url) { + url = getApiEndpointUrl(this.entities[Request.type], 'url') + } + + const httpQuery: HttpQuery = { + size: Request.size, + from: Request.from, + sort: Request.sort, + request_format: 'search-query', + response_format: 'compact' + } + + if (Request._sourceExclude) { + httpQuery._source_exclude = Request._sourceExclude.join(',') + } + if (Request._sourceInclude) { + httpQuery._source_include = Request._sourceInclude.join(',') + } + if (Request.q) { + httpQuery.q = Request.q + } + + if (!Request.index || !Request.type) { + throw new Error('Query.index and Query.type are required arguments for executing ElasticSearch query') + } + if (config.elasticsearch.queryMethod === 'GET') { + httpQuery.request = JSON.stringify(rawQueryObject) + } + url = url + '/' + encodeURIComponent(Request.index) + '/' + encodeURIComponent(Request.type) + '/_search' + url = url + '?' + queryString.stringify(httpQuery) + return fetch(url, { method: config.elasticsearch.queryMethod, + mode: 'cors', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: config.elasticsearch.queryMethod === 'POST' ? JSON.stringify(rawQueryObject) : null + }) + .then(resp => { return resp.json() }) + .catch(error => { + throw new Error('FetchError in request to API: ' + error.toString()) + }) + } + + public handleResult (resp, type, start = 0, size = 50): SearchResponse { + if (resp === null) { + throw new Error('Invalid API result - null not exepcted') + } + if (resp.hasOwnProperty('hits')) { + return { + items: map(resp.hits, hit => { + if (type === 'product') { + hit = this.decompactItem(hit, config.products.fieldsToCompact) + if (hit.configurable_children) { + hit.configurable_children = hit.configurable_children.map(childItem => { + return this.decompactItem(childItem, config.products.fieldsToCompact) + }) + } + } + return Object.assign(hit, { slug: hit.slug ? hit.slug : ((hit.hasOwnProperty('url_key') && config.products.useMagentoUrlKeys) ? hit.url_key : (hit.hasOwnProperty('name') ? slugify(hit.name) + '-' + hit.id : '')) }) // TODO: assign slugs server side + }), // TODO: add scoring information + total: resp.total, + start: start, + perPage: size, + aggregations: resp.aggregations, + attributeMetadata: resp.attribute_metadata, + suggestions: resp.suggest + } + } else { + if (resp.error) { + throw new Error(JSON.stringify(resp.error)) + } else { + throw new Error('Unknown error with API catalog result in resultProcessor for entity type \'' + type + '\'') + } + } + } + + public registerEntityType (entityType, { url = '', url_ssr = '', queryProcessor, resultProcessor }) { + this.entities[entityType] = { + queryProcessor: queryProcessor, + resultProcessor: resultProcessor + } + if (url !== '') { + this.entities[entityType]['url'] = url + } + if (url_ssr !== '') { + this.entities[entityType]['url_ssr'] = url_ssr + } + return this + } + + public initBaseTypes () { + const baseTypes = ['product', 'attribute', 'category', 'taxrule', 'review', 'cms_page', 'cms_block', 'cms_hierarchy'] + baseTypes.forEach(type => { + this.registerEntityType(type, { + queryProcessor: (query) => { + // function that can modify the query each time before it's being executed + return query + }, + resultProcessor: (resp, start, size) => { + return this.handleResult(resp, type, start, size) + } + }) + }) + } +} diff --git a/core/lib/search/adapter/api/elasticsearch/boost.js b/core/lib/search/adapter/api/elasticsearch/boost.js deleted file mode 100644 index 946a97497..000000000 --- a/core/lib/search/adapter/api/elasticsearch/boost.js +++ /dev/null @@ -1,16 +0,0 @@ -import config from 'config' - -export default function getBoosts (attribute = '') { - let searchableAttributes = [ - ] - - if (config.elasticsearch.hasOwnProperty('searchableAttributes') && config.elasticsearch.searchableAttributes[attribute]) { - searchableAttributes = config.elasticsearch.searchableAttributes[attribute] - } - - if (searchableAttributes.hasOwnProperty('boost')) { - return searchableAttributes['boost'] - } - - return 1 -} diff --git a/core/lib/search/adapter/api/elasticsearch/mapping.js b/core/lib/search/adapter/api/elasticsearch/mapping.js deleted file mode 100644 index 3fc8ee7ee..000000000 --- a/core/lib/search/adapter/api/elasticsearch/mapping.js +++ /dev/null @@ -1,15 +0,0 @@ -import config from 'config' -export default function getMapping (attribute, entityType = 'products') { - let mapping = [ - ] - - if (config.hasOwnProperty(entityType) && config[entityType].hasOwnProperty('filterFieldMapping')) { - mapping = config[entityType].filterFieldMapping - } - - if (mapping.hasOwnProperty(attribute)) { - return mapping[attribute] - } - - return attribute -} diff --git a/core/lib/search/adapter/api/elasticsearch/multimatch.js b/core/lib/search/adapter/api/elasticsearch/multimatch.js deleted file mode 100644 index 1abd481c3..000000000 --- a/core/lib/search/adapter/api/elasticsearch/multimatch.js +++ /dev/null @@ -1,29 +0,0 @@ -import config from 'config' - -function getConfig (queryText) { - let scoringConfig = config.elasticsearch.hasOwnProperty('searchScoring') ? config.elasticsearch.searchScoring : {} - let minimumShouldMatch = scoringConfig.hasOwnProperty('minimum_should_match') ? scoringConfig.minimum_should_match : '75%' - if (config.elasticsearch.queryMethod === 'GET') { - // minimum_should_match param must be have a "%" suffix, which is an illegal char while sending over query string - minimumShouldMatch = encodeURIComponent(minimumShouldMatch) - } - // Create config for multi match query - let multiMatchConfig = { - 'query': queryText, - 'operator': scoringConfig.operator ? scoringConfig.operator : 'or', - 'fuzziness': scoringConfig.fuzziness ? scoringConfig.fuzziness : '2', - 'cutoff_frequency': scoringConfig.cutoff_frequency ? scoringConfig.cutoff_frequency : '0.01', - 'max_expansions': scoringConfig.max_expansions ? scoringConfig.max_expansions : '3', - 'prefix_length': scoringConfig.prefix_length ? scoringConfig.prefix_length : '1', - 'minimum_should_match': minimumShouldMatch, - 'tie_breaker': scoringConfig.tie_breaker ? scoringConfig.tie_breaker : '1' - } - if (scoringConfig.hasOwnProperty('analyzer')) { - multiMatchConfig['analyzer'] = scoringConfig.analyzer - } - return multiMatchConfig -} - -export default function getMultiMatchConfig (queryText) { - return getConfig(queryText) -} diff --git a/core/lib/search/adapter/api/elasticsearch/score.js b/core/lib/search/adapter/api/elasticsearch/score.js deleted file mode 100644 index c3d0b2a04..000000000 --- a/core/lib/search/adapter/api/elasticsearch/score.js +++ /dev/null @@ -1,34 +0,0 @@ -import config from 'config' -export default function getFunctionScores () { - if (!config.elasticsearch.hasOwnProperty('searchScoring')) { - return false - } - let filter = [] - let esScoringAttributes = config.elasticsearch.searchScoring.attributes - - if (!Object.keys(esScoringAttributes).length) { - return false - } - for (const attribute of Object.keys(esScoringAttributes)) { - for (const scoreValue of Object.keys(esScoringAttributes[attribute].scoreValues)) { - let data = { - 'filter': { - 'match': { - [attribute]: scoreValue - } - }, - 'weight': esScoringAttributes[attribute].scoreValues[scoreValue].weight - } - filter.push(data) - } - } - if (filter.length) { - return { 'functions': filter, - 'score_mode': config.score_mode ? config.score_mode : 'multiply', - 'boost_mode': config.boost_mode ? config.boost_mode : 'multiply', - 'max_boost': config.max_boost ? config.max_boost : 100, - 'min_score': config.function_min_score ? config.function_min_score : 1 - } - } - return false -} diff --git a/core/lib/search/adapter/api/elasticsearchQuery.js b/core/lib/search/adapter/api/elasticsearchQuery.js deleted file mode 100644 index fbcf64eb4..000000000 --- a/core/lib/search/adapter/api/elasticsearchQuery.js +++ /dev/null @@ -1,121 +0,0 @@ -import getFunctionScores from './elasticsearch/score' -import getMultiMatchConfig from './elasticsearch/multimatch' -import getBoosts from './elasticsearch/boost' -import getMapping from './elasticsearch/mapping' -import cloneDeep from 'lodash-es/cloneDeep' -import config from 'config' - -export async function prepareElasticsearchQueryBody (searchQuery) { - const bodybuilder = await import(/* webpackChunkName: "bodybuilder" */ 'bodybuilder') - const optionsPrefix = '_options' - const queryText = searchQuery.getSearchText() - const rangeOperators = ['gt', 'lt', 'gte', 'lte', 'moreq', 'from', 'to'] - let query = bodybuilder.default() - - // process applied filters - const appliedFilters = cloneDeep(searchQuery.getAppliedFilters()) // copy as function below modifies the object - if (appliedFilters.length > 0) { - let hasCatalogFilters = false - - // apply default filters - appliedFilters.forEach(filter => { - if (filter.scope === 'default') { - if (Object.keys(filter.value).every(v => rangeOperators.includes(v))) { - // process range filters - query = query.filter('range', filter.attribute, filter.value) - } else { - // process terms filters - filter.value = filter.value[Object.keys(filter.value)[0]] - if (!Array.isArray(filter.value)) { - filter.value = [filter.value] - } - query = query.filter('terms', getMapping(filter.attribute), filter.value) - } - } else if (filter.scope === 'catalog') { - hasCatalogFilters = true - } - }) - - // apply catalog scope filters - let attrFilterBuilder = (filterQr, attrPostfix = '') => { - appliedFilters.forEach(catalogfilter => { - const valueKeys = Object.keys(catalogfilter.value) - if (catalogfilter.scope === 'catalog' && valueKeys.length) { - const isRange = valueKeys.filter(value => rangeOperators.indexOf(value) !== -1) - if (isRange.length) { - let rangeAttribute = catalogfilter.attribute - // filter by product fiunal price - if (rangeAttribute === 'price') { - rangeAttribute = config.products.priceFilterKey - } - // process range filters - filterQr = filterQr.andFilter('range', rangeAttribute, catalogfilter.value) - } else { - // process terms filters - let newValue = catalogfilter.value[Object.keys(catalogfilter.value)[0]] - if (!Array.isArray(newValue)) { - newValue = [newValue] - } - if (attrPostfix === '') { - filterQr = filterQr.andFilter('terms', getMapping(catalogfilter.attribute), newValue) - } else { - filterQr = filterQr.andFilter('terms', catalogfilter.attribute + attrPostfix, newValue) - } - } - } - }) - return filterQr - } - - if (hasCatalogFilters) { - query = query.filterMinimumShouldMatch(1).orFilter('bool', attrFilterBuilder) - .orFilter('bool', (b) => attrFilterBuilder(b, optionsPrefix).filter('match', 'type_id', 'configurable')) // the queries can vary based on the product type - } - } - - // Add aggregations for catalog filters - const allFilters = searchQuery.getAvailableFilters() - if (allFilters.length > 0) { - for (let attrToFilter of allFilters) { - if (attrToFilter.scope === 'catalog') { - if (attrToFilter.field !== 'price') { - let aggregationSize = { size: config.products.filterAggregationSize[attrToFilter.field] || config.products.filterAggregationSize.default } - query = query.aggregation('terms', getMapping(attrToFilter.field), aggregationSize) - query = query.aggregation('terms', attrToFilter.field + optionsPrefix, aggregationSize) - } else { - query = query.aggregation('terms', attrToFilter.field) - query.aggregation('range', 'price', config.products.priceFilters) - } - } - } - } - // Get searchable fields based on user-defined config. - let getQueryBody = function (b) { - let searchableAttributes = config.elasticsearch.hasOwnProperty('searchableAttributes') ? config.elasticsearch.searchableAttributes : { 'name': { 'boost': 1 } } - let searchableFields = [ - ] - for (const attribute of Object.keys(searchableAttributes)) { - searchableFields.push(attribute + '^' + getBoosts(attribute)) - } - return b.orQuery('multi_match', 'fields', searchableFields, getMultiMatchConfig(queryText)) - .orQuery('bool', b => b.orQuery('terms', 'configurable_children.sku', queryText.split('-')) - .orQuery('match_phrase', 'sku', { query: queryText, boost: 1 }) - .orQuery('match_phrase', 'configurable_children.sku', { query: queryText, boost: 1 }) - ) - } - if (queryText !== '') { - let functionScore = getFunctionScores() - // Build bool or function_scrre accordingly - if (functionScore) { - query = query.query('function_score', functionScore, getQueryBody) - } else { - query = query.query('bool', getQueryBody) - } - } - const queryBody = query.build() - if (searchQuery.suggest) { - queryBody.suggest = searchQuery.suggest - } - - return queryBody -} diff --git a/core/lib/search/adapter/api/searchAdapter.ts b/core/lib/search/adapter/api/searchAdapter.ts index c64fe343f..48c29a7b3 100644 --- a/core/lib/search/adapter/api/searchAdapter.ts +++ b/core/lib/search/adapter/api/searchAdapter.ts @@ -1,13 +1,14 @@ import map from 'lodash-es/map' -import { prepareElasticsearchQueryBody } from '@vue-storefront/core/lib/search/adapter/api/elasticsearchQuery' +import { elasticsearch } from 'storefront-query-builder' import fetch from 'isomorphic-fetch' import { slugify, processURLAddress } from '@vue-storefront/core/helpers' import queryString from 'query-string' import { currentStoreView, prepareStoreView } from '@vue-storefront/core/lib/multistore' -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' +import { SearchQuery } from 'storefront-query-builder' import HttpQuery from '@vue-storefront/core/types/search/HttpQuery' import { SearchResponse } from '@vue-storefront/core/types/search/SearchResponse' import config from 'config' +import getApiEndpointUrl from '@vue-storefront/core/helpers/getApiEndpointUrl'; export class SearchAdapter { public entities: any @@ -23,7 +24,8 @@ export class SearchAdapter { } let ElasticsearchQueryBody = {} if (Request.searchQuery instanceof SearchQuery) { - ElasticsearchQueryBody = await prepareElasticsearchQueryBody(Request.searchQuery) + const bodybuilder = await import(/* webpackChunkName: "bodybuilder" */ 'bodybuilder') + ElasticsearchQueryBody = await elasticsearch.buildQueryBodyFromSearchQuery({ config, queryChain: bodybuilder.default(), searchQuery: Request.searchQuery }) if (Request.searchQuery.getSearchText() !== '') { ElasticsearchQueryBody['min_score'] = config.elasticsearch.min_score } @@ -42,10 +44,10 @@ export class SearchAdapter { Request.index = storeView.elasticsearch.index - let url = processURLAddress(storeView.elasticsearch.host) + let url = processURLAddress(getApiEndpointUrl(storeView.elasticsearch, 'host')) if (this.entities[Request.type].url) { - url = this.entities[Request.type].url + url = getApiEndpointUrl(this.entities[Request.type], 'url') } const httpQuery: HttpQuery = { @@ -72,7 +74,9 @@ export class SearchAdapter { } url = url + '/' + encodeURIComponent(Request.index) + '/' + encodeURIComponent(Request.type) + '/_search' url = url + '?' + queryString.stringify(httpQuery) - return fetch(url, { method: config.elasticsearch.queryMethod, + + return fetch(url, { + method: config.elasticsearch.queryMethod, mode: 'cors', headers: { 'Accept': 'application/json', @@ -99,6 +103,7 @@ export class SearchAdapter { start: start, perPage: size, aggregations: resp.aggregations, + attributeMetadata: resp.attribute_metadata, suggestions: resp.suggest } } else { @@ -111,7 +116,7 @@ export class SearchAdapter { } } - public registerEntityType (entityType, { url = '', queryProcessor, resultProcessor }) { + public registerEntityType (entityType, { url = '', url_ssr = '', queryProcessor, resultProcessor }) { this.entities[entityType] = { queryProcessor: queryProcessor, resultProcessor: resultProcessor @@ -119,6 +124,9 @@ export class SearchAdapter { if (url !== '') { this.entities[entityType]['url'] = url } + if (url_ssr !== '') { + this.entities[entityType]['url_ssr'] = url_ssr + } return this } diff --git a/core/lib/search/adapter/searchAdapterFactory.js b/core/lib/search/adapter/searchAdapterFactory.js index 9a322ca33..9895d2eb6 100644 --- a/core/lib/search/adapter/searchAdapterFactory.js +++ b/core/lib/search/adapter/searchAdapterFactory.js @@ -1,5 +1,4 @@ import { server } from 'config' -import { Logger } from '@vue-storefront/core/lib/logger' let instances = {} const isImplementingSearchAdapterInterface = (obj) => { @@ -10,12 +9,12 @@ export const getSearchAdapter = async (adapterName = server.api) => { let SearchAdapterModule try { - SearchAdapterModule = await import(/* webpackChunkName: "vsf-search-adapter-" */ `src/search/adapter/${adapterName}/searchAdapter`) + SearchAdapterModule = await import(/* webpackChunkName: "vsf-search-adapter-[request]" */ `src/search/adapter/${adapterName}/searchAdapter`) } catch {} if (!SearchAdapterModule) { try { - SearchAdapterModule = await import(/* webpackChunkName: "vsf-search-adapter-" */ `./${adapterName}/searchAdapter`) + SearchAdapterModule = await import(/* webpackChunkName: "vsf-search-adapter-[request]" */ `./${adapterName}/searchAdapter`) } catch {} } diff --git a/core/lib/search/searchQuery.js b/core/lib/search/searchQuery.js deleted file mode 100644 index 8eec238a8..000000000 --- a/core/lib/search/searchQuery.js +++ /dev/null @@ -1,80 +0,0 @@ -class SearchQuery { - /** - */ - constructor () { - this._availableFilters = [] - this._appliedFilters = [] - this._searchText = '' - } - - /** - * @return {Array} array of all available filters objects - */ - getAvailableFilters () { - return this._availableFilters - } - - /** - * @return {Array} array of applied filters objects - */ - getAppliedFilters () { - return this._appliedFilters - } - - /** - * @return {String} - */ - getSearchText () { - return this._searchText - } - - /** - * @param {Object} - * @return {Object} - */ - applyFilter ({ key, value, scope = 'default', options = Object }) { - this._appliedFilters.push({ - attribute: key, - value: value, - scope: scope, - options: options - }) - - return this - } - - /** - * @param {Object} - * @return {Object} - */ - addAvailableFilter ({ field, scope = 'default', options = {} }) { - // value can has only String, Array or numeric type - this._availableFilters.push({ - field: field, - scope: scope, - options: options - }) - - return this - } - - /** - * @param {Array} filters - * @return {Object} - */ - setAvailableFilters (filters) { - this._availableFilters = filters - return this - } - - /** - * @param {String} searchText - * @return {Object} - */ - setSearchText (searchText) { - this._searchText = searchText - return this - } -} - -export default SearchQuery diff --git a/core/lib/types.ts b/core/lib/types.ts index ed2dc34b3..1791391ea 100644 --- a/core/lib/types.ts +++ b/core/lib/types.ts @@ -37,6 +37,10 @@ export interface StoreView { defaultLocale: string, currencyCode: string, currencySign: string, + currencyDecimal: string, + currencyGroup: string, + fractionDigits: number, + priceFormat: string, dateFormat: string }, seo: { diff --git a/core/modules/cart/helpers/cartCacheHandler.ts b/core/modules/cart/helpers/cartCacheHandler.ts index 7041e16fb..683d0cedc 100644 --- a/core/modules/cart/helpers/cartCacheHandler.ts +++ b/core/modules/cart/helpers/cartCacheHandler.ts @@ -1,4 +1,5 @@ -import * as types from '../store/mutation-types'; +import * as types from '../store/mutation-types' +import { Logger } from '@vue-storefront/core/lib/logger' import { StorageManager } from '@vue-storefront/core/lib/storage-manager' @@ -15,19 +16,19 @@ export function cartCacheHandlerFactory (Vue) { type.endsWith(types.CART_UPD_ITEM_PROPS) ) { return StorageManager.get('cart').setItem('current-cart', state.cart.cartItems).catch((reason) => { - console.error(reason) // it doesn't work on SSR + Logger.error(reason)() // it doesn't work on SSR }) // populate cache } else if ( type.endsWith(types.CART_LOAD_CART_SERVER_TOKEN) ) { return StorageManager.get('cart').setItem('current-cart-token', state.cart.cartServerToken).catch((reason) => { - console.error(reason) + Logger.error(reason)() }) } else if ( type.endsWith(types.CART_SET_ITEMS_HASH) ) { return StorageManager.get('cart').setItem('current-cart-hash', state.cart.cartItemsHash).catch((reason) => { - console.error(reason) + Logger.error(reason)() }) } } diff --git a/core/modules/cart/helpers/createCartItemForUpdate.ts b/core/modules/cart/helpers/createCartItemForUpdate.ts index 6c0224eda..6b13be97a 100644 --- a/core/modules/cart/helpers/createCartItemForUpdate.ts +++ b/core/modules/cart/helpers/createCartItemForUpdate.ts @@ -5,6 +5,7 @@ const createCartItemForUpdate = (clientItem: CartItem, serverItem: any, updateId const sku = clientItem.parentSku && config.cart.setConfigurableProductOptions ? clientItem.parentSku : clientItem.sku const cartItem = { sku, + ...((serverItem && serverItem.item_id) ? { item_id: serverItem.item_id } : {}), qty: mergeQty ? (clientItem.qty + serverItem.qty) : clientItem.qty, product_option: clientItem.product_option } as any as CartItem diff --git a/core/modules/cart/helpers/getProductConfiguration.ts b/core/modules/cart/helpers/getProductConfiguration.ts index df5a9b8f9..d45200279 100644 --- a/core/modules/cart/helpers/getProductConfiguration.ts +++ b/core/modules/cart/helpers/getProductConfiguration.ts @@ -6,8 +6,8 @@ const ATTRIBUTES = ['color', 'size'] const getProductConfiguration = (product: CartItem): ProductConfiguration => { const options = getProductOptions(product) - const getAttributesFields = (attributeCode) => - (options[attributeCode] || []).find(c => c.id === parseInt(product[attributeCode])) + const getAttributesFields = (attributeCode) => + (options[attributeCode] || []).find(c => String(c.id) === String(product[attributeCode])) if (!options) { return null diff --git a/core/modules/cart/helpers/optimizeProduct.ts b/core/modules/cart/helpers/optimizeProduct.ts index 20d6326e8..16370a516 100644 --- a/core/modules/cart/helpers/optimizeProduct.ts +++ b/core/modules/cart/helpers/optimizeProduct.ts @@ -4,7 +4,7 @@ import omit from 'lodash-es/omit' import pullAll from 'lodash-es/pullAll' const optimizeProduct = (product: CartItem): CartItem => { - if (!config.entities.optimize || !config.entities.optimizeShoppingCart) { + if (!config.entities.optimizeShoppingCart) { return product } diff --git a/core/modules/cart/helpers/productsEquals.ts b/core/modules/cart/helpers/productsEquals.ts index ad95e4989..7af6cb287 100644 --- a/core/modules/cart/helpers/productsEquals.ts +++ b/core/modules/cart/helpers/productsEquals.ts @@ -60,6 +60,7 @@ const makeCheck = (product1: CartItem, product2: CartItem, checks: string[]): bo return true } } + return false } const productsEquals = (product1: CartItem, product2: CartItem): boolean => { diff --git a/core/modules/cart/store/actions/itemActions.ts b/core/modules/cart/store/actions/itemActions.ts index 3ce9518e9..02f0625fb 100644 --- a/core/modules/cart/store/actions/itemActions.ts +++ b/core/modules/cart/store/actions/itemActions.ts @@ -1,6 +1,5 @@ import * as types from '@vue-storefront/core/modules/cart/store/mutation-types' import { Logger } from '@vue-storefront/core/lib/logger' -import { configureProductAsync } from '@vue-storefront/core/modules/catalog/helpers' import { prepareProductsToAdd, productsEquals, @@ -14,11 +13,11 @@ import config from 'config' const itemActions = { async configureItem (context, { product, configuration }) { const { commit, dispatch, getters } = context - const variant = configureProductAsync(context, { + const variant = await dispatch('product/getProductVariant', { product, - configuration, - selectDefaultVariant: false - }) + configuration + }, { root: true }) + const itemWithSameSku = getters.getCartItems.find(item => item.sku === variant.sku) if (itemWithSameSku && product.sku !== variant.sku) { diff --git a/core/modules/cart/store/actions/productActions.ts b/core/modules/cart/store/actions/productActions.ts index f3f4ec28d..fa70ec7ee 100644 --- a/core/modules/cart/store/actions/productActions.ts +++ b/core/modules/cart/store/actions/productActions.ts @@ -1,4 +1,4 @@ -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' +import { SearchQuery } from 'storefront-query-builder' const productActions = { async findProductOption ({ dispatch }, { serverItem }) { @@ -6,7 +6,15 @@ const productActions = { let query = new SearchQuery() query = query.applyFilter({ key: 'configurable_children.sku', value: { 'eq': serverItem.sku } }) - const { items } = await dispatch('product/list', { query, start: 0, size: 1, updateState: false }, { root: true }) + const { items } = await dispatch('product/findProducts', { + query, + start: 0, + size: 1, + options: { + populateRequestCacheTags: false, + prefetchGroupProducts: false + } + }, { root: true }) return items.length >= 1 ? { sku: items[0].sku, childSku: serverItem.sku } : null } @@ -16,7 +24,7 @@ const productActions = { async getProductVariant ({ dispatch }, { serverItem }) { try { const options = await dispatch('findProductOption', { serverItem }) - const singleProduct = await dispatch('product/single', { options, assignDefaultVariant: true, setCurrentProduct: false, selectDefaultVariant: false }, { root: true }) + const singleProduct = await dispatch('product/single', { options }, { root: true }) return { ...singleProduct, diff --git a/core/modules/cart/store/getters.ts b/core/modules/cart/store/getters.ts index d88157147..523381646 100644 --- a/core/modules/cart/store/getters.ts +++ b/core/modules/cart/store/getters.ts @@ -30,7 +30,7 @@ const getters: GetterTree = { getItemsTotalQuantity: ({ cartItems }) => config.cart.minicartCountType === 'items' ? cartItems.length : sumBy(cartItems, p => p.qty), getCoupon: ({ platformTotals }): AppliedCoupon | false => !(platformTotals && platformTotals.hasOwnProperty('coupon_code')) ? false : { code: platformTotals.coupon_code, discount: platformTotals.discount_amount }, - isVirtualCart: ({ cartItems }) => cartItems.every(itm => itm.type_id === 'downloadable' || itm.type_id === 'virtual'), + isVirtualCart: ({ cartItems }) => cartItems.length ? cartItems.every(itm => itm.type_id === 'downloadable' || itm.type_id === 'virtual') : false, canUpdateMethods: (state, getters) => getters.isCartSyncEnabled && getters.isCartConnected, canSyncTotals: (state, getters) => getters.isTotalsSyncEnabled && getters.isCartConnected, isCartEmpty: state => state.cartItems.length === 0, diff --git a/core/modules/cart/test/unit/helpers/cartCacheHandler.spec.ts b/core/modules/cart/test/unit/helpers/cartCacheHandler.spec.ts index c53ee9c6e..b6a50c185 100644 --- a/core/modules/cart/test/unit/helpers/cartCacheHandler.spec.ts +++ b/core/modules/cart/test/unit/helpers/cartCacheHandler.spec.ts @@ -2,6 +2,7 @@ import Vue from 'vue' import Vuex from 'vuex' import * as types from '../../../store/mutation-types' +import { Logger } from '@vue-storefront/core/lib/logger' const StorageManager = { cart: { @@ -20,6 +21,12 @@ jest.mock('@vue-storefront/core/helpers', () => ({ jest.mock('@vue-storefront/core/app', () => ({ createApp: jest.fn() })) jest.mock('@vue-storefront/i18n', () => ({ loadLanguageAsync: jest.fn() })) +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + error: () => () => {} + } +})) + Vue.use(Vuex); describe('Cart afterRegistration', () => { @@ -55,7 +62,7 @@ describe('Cart afterRegistration', () => { } }; - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + const consoleErrorSpy = jest.spyOn(Logger, 'error'); StorageManager.get('cart').setItem.mockImplementationOnce(() => Promise.reject('foo')); @@ -86,7 +93,7 @@ describe('Cart afterRegistration', () => { } }; - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + const consoleErrorSpy = jest.spyOn(Logger, 'error'); StorageManager.get('cart').setItem.mockImplementationOnce(() => Promise.reject('foo')); @@ -102,7 +109,7 @@ describe('Cart afterRegistration', () => { } }; - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementationOnce(() => {}); + const consoleErrorSpy = jest.spyOn(Logger, 'error'); StorageManager.get('cart').setItem.mockImplementationOnce(() => Promise.reject('foo')); diff --git a/core/modules/cart/test/unit/helpers/productEquals.spec.ts b/core/modules/cart/test/unit/helpers/productEquals.spec.ts index d54957961..9e8e40bd1 100644 --- a/core/modules/cart/test/unit/helpers/productEquals.spec.ts +++ b/core/modules/cart/test/unit/helpers/productEquals.spec.ts @@ -110,6 +110,12 @@ describe('Cart productEquals', () => { expect(productsEquals(product1, product2)).toBeTruthy() }); + it('returns false because bundle products have not the same options selected', async () => { + const product1 = createBundleProduct({ id: 1, sku: 'WG-001', type_id: 'bundle', options: [2, 2, 5, 8] }) + const product2 = createBundleProduct({ id: 2, sku: 'WG-001', type_id: 'bundle', options: [2, 4, 5, 8] }) + expect(productsEquals(product1, product2)).toBeFalsy() + }); + it('returns true because products have the same server id', async () => { const product1 = createCustomOptionsProduct({ id: 1, sku: 'WG-001', options: null }) const product2 = createCustomOptionsProduct({ id: 1, sku: 'WG-001', options: [2, 4, 5, 8] }) diff --git a/core/modules/cart/test/unit/store/connectActions.spec.ts b/core/modules/cart/test/unit/store/connectActions.spec.ts index 4679d9a9b..5c12c7255 100644 --- a/core/modules/cart/test/unit/store/connectActions.spec.ts +++ b/core/modules/cart/test/unit/store/connectActions.spec.ts @@ -34,7 +34,6 @@ jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ } })); jest.mock('@vue-storefront/core/app', () => ({ router: jest.fn() })); -jest.mock('@vue-storefront/core/lib/search/searchQuery', () => jest.fn()); jest.mock('@vue-storefront/core/helpers', () => ({ get isServer () { return true diff --git a/core/modules/cart/test/unit/store/couponActions.spec.ts b/core/modules/cart/test/unit/store/couponActions.spec.ts index 61a8d9922..eced8f89c 100644 --- a/core/modules/cart/test/unit/store/couponActions.spec.ts +++ b/core/modules/cart/test/unit/store/couponActions.spec.ts @@ -31,7 +31,6 @@ jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ } })); jest.mock('@vue-storefront/core/app', () => ({ router: jest.fn() })); -jest.mock('@vue-storefront/core/lib/search/searchQuery', () => jest.fn()); jest.mock('@vue-storefront/core/helpers', () => ({ get isServer () { return true diff --git a/core/modules/cart/test/unit/store/itemActions.spec.ts b/core/modules/cart/test/unit/store/itemActions.spec.ts index a68bd0444..89b1517b8 100644 --- a/core/modules/cart/test/unit/store/itemActions.spec.ts +++ b/core/modules/cart/test/unit/store/itemActions.spec.ts @@ -3,6 +3,7 @@ import { configureProductAsync } from '@vue-storefront/core/modules/catalog/help import { prepareProductsToAdd, productsEquals, validateProduct } from '@vue-storefront/core/modules/cart/helpers' import cartActions from '@vue-storefront/core/modules/cart/store/actions'; import { createContextMock } from '@vue-storefront/unit-tests/utils'; +import config from 'config'; jest.mock('@vue-storefront/core/store', () => ({ dispatch: jest.fn(), @@ -34,7 +35,6 @@ jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ } })); jest.mock('@vue-storefront/core/app', () => ({ router: jest.fn() })) -jest.mock('@vue-storefront/core/lib/search/searchQuery', () => jest.fn()) jest.mock('@vue-storefront/core/modules/catalog/helpers', () => ({ configureProductAsync: jest.fn() })); @@ -61,20 +61,25 @@ jest.mock('@vue-storefront/core/helpers', () => ({ }, processLocalizedURLAddress: (url) => url })); +jest.mock('config', () => ({})); describe('Cart itemActions', () => { it('configures item and deletes when there is same sku', async () => { const product1 = { sku: 1, name: 'product1', server_item_id: 1 } const product2 = { sku: 2, name: 'product2', server_item_id: 2 } - const configureProductAsyncMock = configureProductAsync as jest.Mock - configureProductAsyncMock.mockImplementation(() => product2) - const contextMock = createContextMock({ getters: { isCartSyncEnabled: true, getCartItems: [product2] - } + }, + dispatch: jest.fn((actionName) => { + switch (actionName) { + case 'product/getProductVariant': { + return product2 + } + } + }) }) await (cartActions as any).configureItem(contextMock, { product: product1, configuration: {} }) @@ -87,14 +92,18 @@ describe('Cart itemActions', () => { const product1 = { sku: 1, name: 'product1', server_item_id: 1 } const product2 = { sku: 2, name: 'product2', server_item_id: 2 } - const configureProductAsyncMock = configureProductAsync as jest.Mock - configureProductAsyncMock.mockImplementation(() => product2) - const contextMock = createContextMock({ getters: { isCartSyncEnabled: true, getCartItems: [product1] - } + }, + dispatch: jest.fn((actionName) => { + switch (actionName) { + case 'product/getProductVariant': { + return product2 + } + } + }) }) await (cartActions as any).configureItem(contextMock, { product: product1, configuration: {} }) diff --git a/core/modules/cart/test/unit/store/mergeActions.spec.ts b/core/modules/cart/test/unit/store/mergeActions.spec.ts index fe3313afa..61ed92c67 100644 --- a/core/modules/cart/test/unit/store/mergeActions.spec.ts +++ b/core/modules/cart/test/unit/store/mergeActions.spec.ts @@ -42,7 +42,6 @@ jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ } })); jest.mock('@vue-storefront/core/app', () => ({ router: jest.fn() })); -jest.mock('@vue-storefront/core/lib/search/searchQuery', () => jest.fn()); jest.mock('@vue-storefront/core/modules/catalog/helpers', () => ({ configureProductAsync: jest.fn() })); diff --git a/core/modules/cart/test/unit/store/methodsActions.spec.ts b/core/modules/cart/test/unit/store/methodsActions.spec.ts index 3fa70c4ce..8fe7dc93a 100644 --- a/core/modules/cart/test/unit/store/methodsActions.spec.ts +++ b/core/modules/cart/test/unit/store/methodsActions.spec.ts @@ -39,7 +39,7 @@ jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ } })); jest.mock('@vue-storefront/core/app', () => ({ router: jest.fn() })); -jest.mock('@vue-storefront/core/lib/search/searchQuery', () => jest.fn()); +jest.mock('storefront-query-builder', () => jest.fn()); jest.mock('@vue-storefront/core/modules/catalog/helpers', () => ({ configureProductAsync: jest.fn() })); diff --git a/core/modules/cart/test/unit/store/productActions.spec.ts b/core/modules/cart/test/unit/store/productActions.spec.ts index 21491f416..24395aaaf 100644 --- a/core/modules/cart/test/unit/store/productActions.spec.ts +++ b/core/modules/cart/test/unit/store/productActions.spec.ts @@ -36,7 +36,6 @@ jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ } })); jest.mock('@vue-storefront/core/app', () => ({ router: jest.fn() })); -jest.mock('@vue-storefront/core/lib/search/searchQuery', () => jest.fn(() => ({ applyFilter: jest.fn() }))); jest.mock('@vue-storefront/core/modules/catalog/helpers', () => ({ configureProductAsync: jest.fn() })); @@ -77,7 +76,7 @@ describe('Cart productActions', () => { (contextMock.dispatch as jest.Mock).mockImplementationOnce(() => ({ items: [serverItem] })); const result = await (cartActions as any).findProductOption(contextMock, { serverItem }); - expect(contextMock.dispatch).toBeCalledWith('product/list', { start: 0, size: 1, updateState: false }, { root: true }) + expect(contextMock.dispatch).toBeCalledWith('product/findProducts', { query: { _appliedFilters: [{ attribute: 'configurable_children.sku', options: Object, scope: 'default', value: { eq: 1 } }], _availableFilters: [], _appliedSort: [], _searchText: '' }, size: 1, start: 0, options: { populateRequestCacheTags: false, prefetchGroupProducts: false } }, { root: true }) expect(result).toEqual({ childSku: 1, sku: 1 }) }); @@ -90,7 +89,7 @@ describe('Cart productActions', () => { const result = await (cartActions as any).getProductVariant(contextMock, { serverItem }); expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'findProductOption', { serverItem }) - expect(contextMock.dispatch).toHaveBeenNthCalledWith(2, 'product/single', { options: {}, assignDefaultVariant: true, setCurrentProduct: false, selectDefaultVariant: false }, { root: true }) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(2, 'product/single', { options: {} }, { root: true }) expect(result).toEqual({ name: 'product1', opt1: 1, diff --git a/core/modules/cart/test/unit/store/quantityActions.spec.ts b/core/modules/cart/test/unit/store/quantityActions.spec.ts index f41a9c3ff..ca6436e3f 100644 --- a/core/modules/cart/test/unit/store/quantityActions.spec.ts +++ b/core/modules/cart/test/unit/store/quantityActions.spec.ts @@ -37,7 +37,6 @@ jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ } })); jest.mock('@vue-storefront/core/app', () => ({ router: jest.fn() })); -jest.mock('@vue-storefront/core/lib/search/searchQuery', () => jest.fn(() => ({ applyFilter: jest.fn() }))); jest.mock('@vue-storefront/core/modules/catalog/helpers', () => ({ configureProductAsync: jest.fn() })); diff --git a/core/modules/cart/test/unit/store/synchronizeActions.spec.ts b/core/modules/cart/test/unit/store/synchronizeActions.spec.ts index ae866ecb0..27c184079 100644 --- a/core/modules/cart/test/unit/store/synchronizeActions.spec.ts +++ b/core/modules/cart/test/unit/store/synchronizeActions.spec.ts @@ -41,7 +41,6 @@ jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ } })); jest.mock('@vue-storefront/core/app', () => ({ router: jest.fn() })); -jest.mock('@vue-storefront/core/lib/search/searchQuery', () => jest.fn(() => ({ applyFilter: jest.fn() }))); jest.mock('@vue-storefront/core/modules/catalog/helpers', () => ({ configureProductAsync: jest.fn() })); diff --git a/core/modules/cart/test/unit/store/totalsActions.spec.ts b/core/modules/cart/test/unit/store/totalsActions.spec.ts index c461cb8c0..331970394 100644 --- a/core/modules/cart/test/unit/store/totalsActions.spec.ts +++ b/core/modules/cart/test/unit/store/totalsActions.spec.ts @@ -43,7 +43,6 @@ jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ } })); jest.mock('@vue-storefront/core/app', () => ({ router: jest.fn() })); -jest.mock('@vue-storefront/core/lib/search/searchQuery', () => jest.fn(() => ({ applyFilter: jest.fn() }))); jest.mock('@vue-storefront/core/modules/catalog/helpers', () => ({ configureProductAsync: jest.fn() })); diff --git a/core/modules/catalog-next/store/category/CategoryState.ts b/core/modules/catalog-next/store/category/CategoryState.ts index d67909ec5..d3af9e7a3 100644 --- a/core/modules/catalog-next/store/category/CategoryState.ts +++ b/core/modules/catalog-next/store/category/CategoryState.ts @@ -6,5 +6,6 @@ export default interface CategoryState { notFoundCategoryIds: string[], filtersMap: { [id: string]: any }, products: Product[], - searchProductsStats: any + searchProductsStats: any, + menuCategories: Category[] } diff --git a/core/modules/catalog-next/store/category/actions.ts b/core/modules/catalog-next/store/category/actions.ts index 01dbfea9d..05dcb3e43 100644 --- a/core/modules/catalog-next/store/category/actions.ts +++ b/core/modules/catalog-next/store/category/actions.ts @@ -4,25 +4,24 @@ import * as types from './mutation-types' import RootState from '@vue-storefront/core/types/RootState' import CategoryState from './CategoryState' import { quickSearchByQuery } from '@vue-storefront/core/lib/search' -import { buildFilterProductsQuery, isServer } from '@vue-storefront/core/helpers' +import { buildFilterProductsQuery } from '@vue-storefront/core/helpers' import { router } from '@vue-storefront/core/app' -import { currentStoreView, localizedDispatcherRoute, localizedDispatcherRouteName } from '@vue-storefront/core/lib/multistore' +import { localizedDispatcherRoute } from '@vue-storefront/core/lib/multistore' import FilterVariant from '../../types/FilterVariant' import { CategoryService } from '@vue-storefront/core/data-resolver' import { changeFilterQuery } from '../../helpers/filterHelpers' import { products, entities } from 'config' -import { configureProductAsync } from '@vue-storefront/core/modules/catalog/helpers' import { DataResolver } from 'core/data-resolver/types/DataResolver'; import { Category } from '../../types/Category'; import { _prepareCategoryPathIds } from '../../helpers/categoryHelpers'; import { prefetchStockItems } from '../../helpers/cacheProductsHelper'; -import { preConfigureProduct } from '@vue-storefront/core/modules/catalog/helpers/search' import chunk from 'lodash-es/chunk' -import Product from 'core/modules/catalog/types/Product'; import omit from 'lodash-es/omit' import cloneDeep from 'lodash-es/cloneDeep' import config from 'config' import { parseCategoryPath } from '@vue-storefront/core/modules/breadcrumbs/helpers' +import createCategoryListQuery from '@vue-storefront/core/modules/catalog/helpers/createCategoryListQuery' +import { transformCategoryUrl } from '@vue-storefront/core/modules/url/helpers/transformUrl'; const actions: ActionTree = { async loadCategoryProducts ({ commit, getters, dispatch, rootState }, { route, category, pageSize = 50 } = {}) { @@ -34,17 +33,30 @@ const actions: ActionTree = { } const searchQuery = getters.getCurrentFiltersFrom(route[products.routerFiltersSource], categoryMappedFilters) let filterQr = buildFilterProductsQuery(searchCategory, searchQuery.filters) - const { items, perPage, start, total, aggregations } = await quickSearchByQuery({ + const { items, perPage, start, total, aggregations, attributeMetadata } = await dispatch('product/findProducts', { query: filterQr, sort: searchQuery.sort || `${products.defaultSortBy.attribute}:${products.defaultSortBy.order}`, includeFields: entities.productList.includeFields, excludeFields: entities.productList.excludeFields, - size: pageSize + size: pageSize, + configuration: searchQuery.filters, + options: { + populateRequestCacheTags: true, + prefetchGroupProducts: false, + setProductErrors: false, + fallbackToDefaultWhenNoAvailable: true, + assignProductConfiguration: false, + separateSelectedVariant: false + } + }, { root: true }) + await dispatch('loadAvailableFiltersFrom', { + aggregations, + attributeMetadata, + category: searchCategory, + filters: searchQuery.filters }) - await dispatch('loadAvailableFiltersFrom', { aggregations, category: searchCategory, filters: searchQuery.filters }) commit(types.CATEGORY_SET_SEARCH_PRODUCTS_STATS, { perPage, start, total }) - const configuredProducts = await dispatch('processCategoryProducts', { products: items, filters: searchQuery.filters }) - commit(types.CATEGORY_SET_PRODUCTS, configuredProducts) + commit(types.CATEGORY_SET_PRODUCTS, items) return items }, @@ -55,33 +67,49 @@ const actions: ActionTree = { const searchQuery = getters.getCurrentSearchQuery let filterQr = buildFilterProductsQuery(getters.getCurrentCategory, searchQuery.filters) - const searchResult = await quickSearchByQuery({ + const searchResult = await dispatch('product/findProducts', { query: filterQr, sort: searchQuery.sort || `${products.defaultSortBy.attribute}:${products.defaultSortBy.order}`, start: start + perPage, size: perPage, includeFields: entities.productList.includeFields, - excludeFields: entities.productList.excludeFields - }) + excludeFields: entities.productList.excludeFields, + configuration: searchQuery.filters, + options: { + populateRequestCacheTags: true, + prefetchGroupProducts: false, + setProductErrors: false, + fallbackToDefaultWhenNoAvailable: true, + assignProductConfiguration: false, + separateSelectedVariant: false + } + }, { root: true }) commit(types.CATEGORY_SET_SEARCH_PRODUCTS_STATS, { perPage: searchResult.perPage, start: searchResult.start, total: searchResult.total }) - const configuredProducts = await dispatch('processCategoryProducts', { products: searchResult.items, filters: searchQuery.filters }) - commit(types.CATEGORY_ADD_PRODUCTS, configuredProducts) + + commit(types.CATEGORY_ADD_PRODUCTS, searchResult.items) return searchResult.items }, async cacheProducts ({ commit, getters, dispatch, rootState }, { route } = {}) { + if (config.api.saveBandwidthOverCache) { + return + } + const searchCategory = getters.getCategoryFrom(route.path) || {} const searchQuery = getters.getCurrentFiltersFrom(route[products.routerFiltersSource]) let filterQr = buildFilterProductsQuery(searchCategory, searchQuery.filters) - const cachedProductsResponse = await dispatch('product/list', { // configure and calculateTaxes is being executed in the product/list - we don't need another call in here + const cachedProductsResponse = await dispatch('product/findProducts', { query: filterQr, sort: searchQuery.sort, - updateState: false // not update the product listing - this request is only for caching + options: { + populateRequestCacheTags: false, + prefetchGroupProducts: false + } }, { root: true }) if (products.filterUnavailableVariants) { // prefetch the stock items const skus = prefetchStockItems(cachedProductsResponse, rootState.stock.cache) @@ -91,40 +119,6 @@ const actions: ActionTree = { } } }, - /** - * Calculates products taxes - * Registers URLs - * Configures products - */ - async processCategoryProducts ({ dispatch, rootState }, { products = [], filters = {} } = {}) { - const configuredProducts = await dispatch('configureProducts', { products, filters }) - dispatch('registerCategoryProductsMapping', products) // we don't need to wait for this - return dispatch('tax/calculateTaxes', { products: configuredProducts }, { root: true }) - }, - /** - * Configure configurable products to have first available options selected - * so they can be added to cart/wishlist/compare without manual configuring - */ - async configureProducts ({ rootState }, { products = [], filters = {}, populateRequestCacheTags = config.server.useOutputCacheTagging } = {}) { - return products.map(product => { - product = Object.assign({}, preConfigureProduct({ product, populateRequestCacheTags })) - const configuredProductVariant = configureProductAsync({ rootState, state: { current_configuration: {} } }, { product, configuration: filters, selectDefaultVariant: false, fallbackToDefaultWhenNoAvailable: true, setProductErorrs: false }) - return Object.assign(product, omit(configuredProductVariant, ['visibility'])) - }) - }, - async registerCategoryProductsMapping ({ dispatch }, products = []) { - const { storeCode, appendStoreCode } = currentStoreView() - await Promise.all(products.map(product => { - const { url_path, sku, slug, type_id } = product - return dispatch('url/registerMapping', { - url: localizedDispatcherRoute(url_path, storeCode), - routeData: { - params: { parentSku: product.sku, slug }, - 'name': localizedDispatcherRouteName(type_id + '-product', storeCode, appendStoreCode) - } - }, { root: true }) - })) - }, async findCategories (context, categorySearchOptions: DataResolver.CategorySearchOptions): Promise { return CategoryService.getCategories(categorySearchOptions) }, @@ -148,7 +142,7 @@ const actions: ActionTree = { Vue.prototype.$cacheTags.add(`C${category.id}`) }) } - const notFoundCategories = searchedIds.filter(categoryId => !categories.some(cat => cat.id === parseInt(categoryId))) + const notFoundCategories = searchedIds.filter(categoryId => !categories.some(cat => cat.id === parseInt(categoryId) || cat.id === categoryId)) commit(types.CATEGORY_ADD_CATEGORIES, categories) commit(types.CATEGORY_ADD_NOT_FOUND_CATEGORY_IDS, notFoundCategories) @@ -171,14 +165,17 @@ const actions: ActionTree = { async loadCategoryFilters ({ dispatch, getters }, category) { const searchCategory = category || getters.getCurrentCategory let filterQr = buildFilterProductsQuery(searchCategory) - const { aggregations } = await quickSearchByQuery({ + const { aggregations, attributeMetadata } = await quickSearchByQuery({ query: filterQr, size: config.products.maxFiltersQuerySize, excludeFields: ['*'] }) - await dispatch('loadAvailableFiltersFrom', { aggregations, category }) + await dispatch('loadAvailableFiltersFrom', { aggregations, attributeMetadata: attributeMetadata, category }) }, - async loadAvailableFiltersFrom ({ commit, getters }, { aggregations, category, filters = {} }) { + async loadAvailableFiltersFrom ({ commit, getters, dispatch }, { aggregations, attributeMetadata, category, filters = {} }) { + if (config.entities.attribute.loadByAttributeMetadata) { + await dispatch('attribute/loadCategoryAttributes', { attributeMetadata }, { root: true }) + } const aggregationFilters = getters.getAvailableFiltersFrom(aggregations) const currentCategory = category || getters.getCurrentCategory const categoryMappedFilters = getters.getFiltersMap[currentCategory.id] @@ -189,6 +186,7 @@ const actions: ActionTree = { } commit(types.CATEGORY_SET_CATEGORY_FILTERS, { category, filters: resultFilters }) }, + async switchSearchFilters ({ dispatch }, filterVariants: FilterVariant[] = []) { let currentQuery = router.currentRoute[products.routerFiltersSource] filterVariants.forEach(filterVariant => { @@ -204,7 +202,7 @@ const actions: ActionTree = { }, async loadCategoryBreadcrumbs ({ dispatch, getters }, { category, currentRouteName, omitCurrent = false }) { if (!category) return - const categoryHierarchyIds = _prepareCategoryPathIds(category) // getters.getCategoriesHierarchyMap.find(categoryMapping => categoryMapping.includes(category.id)) + const categoryHierarchyIds = category.parent_ids ? [...category.parent_ids, category.id] : _prepareCategoryPathIds(category) // getters.getCategoriesHierarchyMap.find(categoryMapping => categoryMapping.includes(category.id)) const categoryFilters = Object.assign({ 'id': categoryHierarchyIds }, cloneDeep(config.entities.category.breadcrumbFilterFields)) const categories = await dispatch('loadCategories', { filters: categoryFilters, reloadAll: Object.keys(config.entities.category.breadcrumbFilterFields).length > 0 }) const sorted = [] @@ -216,7 +214,55 @@ const actions: ActionTree = { } await dispatch('breadcrumbs/set', { current: currentRouteName, routes: parseCategoryPath(sorted) }, { root: true }) return sorted - } + }, + /** + * Load categories within specified parent + * @param {Object} commit promise + * @param {Object} parent parent category + */ + async fetchMenuCategories ({ commit, getters, dispatch }, { + parent = null, + key = null, + value = null, + level = null, + onlyActive = true, + onlyNotEmpty = false, + size = 4000, + start = 0, + sort = 'position:asc', + includeFields = (config.entities.optimize ? config.entities.category.includeFields : null), + excludeFields = (config.entities.optimize ? config.entities.category.excludeFields : null), + skipCache = false + }) { + const { searchQuery, isCustomizedQuery } = createCategoryListQuery({ parent, level, key, value, onlyActive, onlyNotEmpty }) + const shouldLoadCategories = skipCache || isCustomizedQuery + + if (shouldLoadCategories) { + const resp = await quickSearchByQuery({ entityType: 'category', query: searchQuery, sort, size, start, includeFields, excludeFields }) + + await dispatch('registerCategoryMapping', { categories: resp.items }) + + commit(types.CATEGORY_UPD_MENU_CATEGORIES, { items: resp.items }) + + return resp + } + + const list = { items: getters.getMenuCategories, total: getters.getMenuCategories.length } + + return list + }, + async registerCategoryMapping ({ dispatch }, { categories }) { + for (let category of categories) { + if (category.url_path) { + await dispatch('url/registerMapping', { + url: localizedDispatcherRoute(category.url_path), + routeData: transformCategoryUrl(category) + }, { root: true }) + } + } + }, + /** Below actions are not used from 1.12 and can be removed to reduce bundle */ + ...require('./deprecatedActions').default } export default actions diff --git a/core/modules/catalog-next/store/category/deprecatedActions.ts b/core/modules/catalog-next/store/category/deprecatedActions.ts new file mode 100644 index 000000000..f6701570e --- /dev/null +++ b/core/modules/catalog-next/store/category/deprecatedActions.ts @@ -0,0 +1,47 @@ +import { currentStoreView, localizedDispatcherRoute, localizedDispatcherRouteName } from '@vue-storefront/core/lib/multistore' +import { preConfigureProduct } from '@vue-storefront/core/modules/catalog/helpers/search' +import omit from 'lodash-es/omit' +import config from 'config' +const { configureProductAsync } = require('@vue-storefront/core/modules/catalog/helpers') + +const actions = { + /** + * Calculates products taxes + * Registers URLs + * Configures products + */ + async processCategoryProducts ({ dispatch, rootState }, { products = [], filters = {} } = {}) { + dispatch('registerCategoryProductsMapping', products) // we don't need to wait for this + const configuredProducts = await dispatch('configureProducts', { products, filters }) + return dispatch('tax/calculateTaxes', { products: configuredProducts }, { root: true }) + }, + /** + * Configure configurable products to have first available options selected + * so they can be added to cart/wishlist/compare without manual configuring + */ + async configureProducts ({ rootState }, { products = [], filters = {}, populateRequestCacheTags = config.server.useOutputCacheTagging } = {}) { + return products.map(product => { + product = Object.assign({}, preConfigureProduct({ product, populateRequestCacheTags })) + const configuredProductVariant = configureProductAsync({ rootState, state: { current_configuration: {} } }, { product, configuration: filters, selectDefaultVariant: false, fallbackToDefaultWhenNoAvailable: true, setProductErorrs: false }) + return Object.assign(product, omit(configuredProductVariant, ['visibility'])) + }) + }, + async registerCategoryProductsMapping ({ dispatch }, products = []) { + const { storeCode, appendStoreCode } = currentStoreView() + await Promise.all(products.map(product => { + const { url_path, sku, slug, type_id } = product + return dispatch('url/registerMapping', { + url: localizedDispatcherRoute(url_path, storeCode), + routeData: { + params: { + parentSku: product.parentSku || product.sku, + slug + }, + 'name': localizedDispatcherRouteName(type_id + '-product', storeCode, appendStoreCode) + } + }, { root: true }) + })) + } +} + +export default actions diff --git a/core/modules/catalog-next/store/category/getters.ts b/core/modules/catalog-next/store/category/getters.ts index 57d2e79a5..924ea644b 100644 --- a/core/modules/catalog-next/store/category/getters.ts +++ b/core/modules/catalog-next/store/category/getters.ts @@ -5,7 +5,7 @@ import CategoryState from './CategoryState' import { compareByLabel } from '../../helpers/categoryHelpers' import { products } from 'config' import FilterVariant from '../../types/FilterVariant' -import { optionLabel } from '../../helpers/optionLabel' +import { optionLabel } from '@vue-storefront/core/modules/catalog/helpers' import trim from 'lodash-es/trim' import toString from 'lodash-es/toString' import forEach from 'lodash-es/forEach' @@ -16,6 +16,7 @@ import { parseCategoryPath } from '@vue-storefront/core/modules/breadcrumbs/help import { _prepareCategoryPathIds, getSearchOptionsFromRouteParams } from '../../helpers/categoryHelpers'; import { currentStoreView, removeStoreCodeFromRoute } from '@vue-storefront/core/lib/multistore' import cloneDeep from 'lodash-es/cloneDeep' +import config from 'config'; function mapCategoryProducts (productsFromState, productsData) { return productsFromState.map(prodState => { @@ -44,7 +45,7 @@ const getters: GetterTree = { }) || {} }, getCurrentCategory: (state, getters, rootState, rootGetters) => { - return getters.getCategoryByParams(rootState.route.params) + return getters.getCategoryByParams({ ...rootGetters['url/getCurrentRoute'].params }) }, getAvailableFiltersFrom: (state, getters, rootState) => (aggregations) => { const filters = {} @@ -139,6 +140,9 @@ const getters: GetterTree = { const totalValue = typeof total === 'object' ? total.value : total return totalValue || 0 + }, + getMenuCategories (state, getters, rootState, rootGetters) { + return state.menuCategories || rootGetters['category/getCategories'] } } diff --git a/core/modules/catalog-next/store/category/index.ts b/core/modules/catalog-next/store/category/index.ts index 8dd910ea1..6d2f9f7ea 100644 --- a/core/modules/catalog-next/store/category/index.ts +++ b/core/modules/catalog-next/store/category/index.ts @@ -12,7 +12,8 @@ export const categoryModule: Module = { notFoundCategoryIds: [], filtersMap: {}, products: [], - searchProductsStats: {} + searchProductsStats: {}, + menuCategories: [] }, getters, actions, diff --git a/core/modules/catalog-next/store/category/mutation-types.ts b/core/modules/catalog-next/store/category/mutation-types.ts index 5e0ed5da0..3234f97a9 100644 --- a/core/modules/catalog-next/store/category/mutation-types.ts +++ b/core/modules/catalog-next/store/category/mutation-types.ts @@ -6,3 +6,4 @@ export const CATEGORY_ADD_CATEGORIES = `${SN_CATEGORY}/ADD_CATEGORIES` export const CATEGORY_ADD_CATEGORY = `${SN_CATEGORY}/ADD_CATEGORY` export const CATEGORY_SET_CATEGORY_FILTERS = `${SN_CATEGORY}/SET_CATEGORY_FILTERS` export const CATEGORY_ADD_NOT_FOUND_CATEGORY_IDS = `${SN_CATEGORY}/ADD_NOT_FOUND_CATEGORY_IDS` +export const CATEGORY_UPD_MENU_CATEGORIES = `${SN_CATEGORY}/UPD_MENU_CATEGORIES` diff --git a/core/modules/catalog-next/store/category/mutations.ts b/core/modules/catalog-next/store/category/mutations.ts index 4580128ad..fcee77454 100644 --- a/core/modules/catalog-next/store/category/mutations.ts +++ b/core/modules/catalog-next/store/category/mutations.ts @@ -6,6 +6,7 @@ import * as types from './mutation-types' import CategoryState from './CategoryState' import { Category } from '../../types/Category' import cloneDeep from 'lodash-es/cloneDeep' +import slugifyCategories from '@vue-storefront/core/modules/catalog/helpers/slugifyCategories' const mutations: MutationTree = { [types.CATEGORY_SET_PRODUCTS] (state, products = []) { @@ -38,6 +39,24 @@ const mutations: MutationTree = { }, [types.CATEGORY_SET_SEARCH_PRODUCTS_STATS] (state, stats = {}) { state.searchProductsStats = stats + }, + [types.CATEGORY_UPD_MENU_CATEGORIES] (state, categories) { + for (let category of categories.items) { + category = slugifyCategories(category) + const catExist = state.menuCategories.find(existingCat => existingCat.id === category.id) + + if (!catExist) { + state.menuCategories.push(category) + } + } + + state.menuCategories.sort((catA, catB) => { + if (catA.position && catB.position) { + if (catA.position < catB.position) return -1 + if (catA.position > catB.position) return 1 + } + return 0 + }) } } diff --git a/core/modules/catalog-next/types/Category.d.ts b/core/modules/catalog-next/types/Category.d.ts index c1b24106f..a5bc993eb 100644 --- a/core/modules/catalog-next/types/Category.d.ts +++ b/core/modules/catalog-next/types/Category.d.ts @@ -20,7 +20,8 @@ export interface Category { url_path: string, url_key: string, children_data: ChildrenData[], - slug: string + slug: string, + position?: number } export interface Filters { diff --git a/core/modules/catalog/components/Search.ts b/core/modules/catalog/components/Search.ts index 7758b807d..f22343331 100644 --- a/core/modules/catalog/components/Search.ts +++ b/core/modules/catalog/components/Search.ts @@ -42,40 +42,58 @@ export const Search = { let searchQuery = prepareQuickSearchQuery(queryText) return searchQuery }, - makeSearch () { + async makeSearch () { if (this.search !== '' && this.search !== undefined) { let query = this.buildSearchQuery(this.search) let startValue = 0; this.start = startValue this.readMore = true - this.$store.dispatch('product/list', { query, start: this.start, configuration: {}, size: this.size, updateState: false }).then(resp => { - this.products = resp.items + try { + const { items } = await this.$store.dispatch('product/findProducts', { + query, + start: this.start, + size: this.size, + options: { + populateRequestCacheTags: false, + prefetchGroupProducts: false + } + }) + this.products = items this.start = startValue + this.size - this.emptyResults = resp.items.length < 1 - }).catch((err) => { + this.emptyResults = items.length < 1 + } catch (err) { Logger.error(err, 'components-search')() - }) + } } else { this.products = [] this.emptyResults = 0 } }, - seeMore () { + async seeMore () { if (this.search !== '' && this.search !== undefined) { let query = this.buildSearchQuery(this.search) let startValue = this.start; - this.$store.dispatch('product/list', { query, start: startValue, size: this.size, updateState: false }).then((resp) => { - let page = Math.floor(resp.total / this.size) - let exceeed = resp.total - this.size * page - if (resp.start === resp.total - exceeed) { + try { + const { items, total, start } = await this.$store.dispatch('product/findProducts', { + query, + start: startValue, + size: this.size, + options: { + populateRequestCacheTags: false, + prefetchGroupProducts: false + } + }) + let page = Math.floor(total / this.size) + let exceeed = total - this.size * page + if (start === total - exceeed) { this.readMore = false } - this.products = this.products.concat(resp.items) + this.products = this.products.concat(items) this.start = startValue + this.size this.emptyResults = this.products.length < 1 - }).catch((err) => { + } catch (err) { Logger.error(err, 'components-search')() - }) + } } else { this.products = [] this.emptyResults = 0 diff --git a/core/modules/catalog/events.ts b/core/modules/catalog/events.ts index f99f03716..da1443376 100644 --- a/core/modules/catalog/events.ts +++ b/core/modules/catalog/events.ts @@ -1,7 +1,9 @@ import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' -import { PRODUCT_SET_CURRENT_CONFIGURATION, PRODUCT_SET_CURRENT } from './store/product/mutation-types' +import { PRODUCT_SET_CURRENT } from './store/product/mutation-types' import omit from 'lodash-es/omit' import config from 'config' +import i18n from '@vue-storefront/core/i18n'; +import { SearchQuery } from 'storefront-query-builder' import { AsyncDataLoader } from '@vue-storefront/core/lib/async-data-loader'; import { currentStoreView } from '@vue-storefront/core/lib/multistore'; import { formatProductLink } from '@vue-storefront/core/modules/url/helpers'; @@ -22,36 +24,44 @@ export const productAfterPriceupdate = async (product, store) => { export const filterChangedProduct = async (filterOption, store, router) => { EventBus.$emit('product-before-configure', { filterOption: filterOption, configuration: store.getters['product/getCurrentProductConfiguration'] }) - const prevOption = store.getters['product/getCurrentProductConfiguration'][filterOption.attribute_code] - let changedConfig = Object.assign({}, store.getters['product/getCurrentProductConfiguration'], { [filterOption.attribute_code]: filterOption }) - const selectedVariant = await store.dispatch('product/configure', { - product: store.getters['product/getCurrentProduct'], + const currentProductConfiguration = store.getters['product/getCurrentProductConfiguration'] + const changedConfig = Object.assign({}, currentProductConfiguration, { [filterOption.attribute_code]: filterOption }) + let searchQuery = new SearchQuery() + searchQuery = searchQuery.applyFilter({ key: 'sku', value: { 'eq': store.getters['product/getCurrentProduct'].parentSku } }) + const { items: [newProductVariant] } = await store.dispatch('product/findProducts', { + query: searchQuery, + size: 1, configuration: changedConfig, - selectDefaultVariant: true, - fallbackToDefaultWhenNoAvailable: false, - setProductErorrs: true + options: { + fallbackToDefaultWhenNoAvailable: false, + setProductErrors: true, + assignProductConfiguration: true, + separateSelectedVariant: true + } }, { root: true }) - if (config.products.setFirstVarianAsDefaultInURL) { - router.push({ params: { childSku: selectedVariant.sku } }) + const { configuration, selectedVariant, options, product_option } = newProductVariant + if (config.products.setFirstVarianAsDefaultInURL && selectedVariant) { + const routeProp = config.seo.useUrlDispatcher ? 'params' : 'query' + router.push({ [routeProp]: { childSku: selectedVariant.sku } }) } - if (!selectedVariant) { - if (prevOption) { - store.commit(prefixMutation(PRODUCT_SET_CURRENT_CONFIGURATION), Object.assign( - {}, - store.getters['product/getCurrentProductConfiguration'], - { - [filterOption.attribute_code]: prevOption - } - ), { root: true }) - } else { - store.commit(prefixMutation(PRODUCT_SET_CURRENT_CONFIGURATION), Object.assign( - {}, - store.getters['product/getCurrentProductConfiguration'], - { - [filterOption.attribute_code]: undefined - } - ), { root: true }) - } + if (selectedVariant) { + const newProductConfiguration = Object.assign( + {}, + store.getters['product/getCurrentProduct'], + selectedVariant, + { configuration, options, product_option } + ) + await store.dispatch('product/setCurrent', newProductConfiguration) + EventBus.$emit('product-after-configure', { product: newProductConfiguration, configuration: configuration, selectedVariant: selectedVariant }) + return selectedVariant + } else { + store.dispatch('notification/spawnNotification', { + type: 'warning', + message: i18n.t( + 'No such configuration for the product. Please do choose another combination of attributes.' + ), + action1: { label: i18n.t('OK') } + }) } } @@ -66,8 +76,8 @@ export const productAfterCustomoptions = async (payload, store) => { priceDeltaInclTax += optionValue.price } if (optionValue.price_type === 'percent' && optionValue.price !== 0) { - priceDelta += ((optionValue.price / 100) * store.getters['product/getOriginalProduct'].price) - priceDeltaInclTax += ((optionValue.price / 100) * store.getters['product/getOriginalProduct'].price_incl_tax) + priceDelta += ((optionValue.price / 100) * store.getters['product/getCurrentProduct'].price) + priceDeltaInclTax += ((optionValue.price / 100) * store.getters['product/getCurrentProduct'].price_incl_tax) } } }) @@ -76,8 +86,8 @@ export const productAfterCustomoptions = async (payload, store) => { {}, store.getters['product/getCurrentProduct'], { - price: store.getters['product/getOriginalProduct'].price + priceDelta, - price_incl_tax: store.getters['product/getOriginalProduct'].price_incl_tax + priceDeltaInclTax + price: store.getters['product/getCurrentProduct'].price + priceDelta, + price_incl_tax: store.getters['product/getCurrentProduct'].price_incl_tax + priceDeltaInclTax } ), { root: true }) } diff --git a/core/modules/catalog/helpers/associatedProducts/buildQuery.ts b/core/modules/catalog/helpers/associatedProducts/buildQuery.ts new file mode 100644 index 000000000..5a409f655 --- /dev/null +++ b/core/modules/catalog/helpers/associatedProducts/buildQuery.ts @@ -0,0 +1,16 @@ +import config from 'config' +import { SearchQuery } from 'storefront-query-builder' + +/** + * Creates simple query that will search product by skus list + */ +export default function buildQuery (skus: string[]): SearchQuery { + let productsQuery = new SearchQuery() + productsQuery = productsQuery + .applyFilter({ key: 'sku', value: { 'in': skus } }) + .applyFilter({ key: 'status', value: { 'in': [1] } }) + if (config.products.listOutOfStockProducts === false) { + productsQuery = productsQuery.applyFilter({ key: 'stock.is_in_stock', value: { 'eq': true } }) + } + return productsQuery +} diff --git a/core/modules/catalog/helpers/associatedProducts/getBundleProductPrice.ts b/core/modules/catalog/helpers/associatedProducts/getBundleProductPrice.ts new file mode 100644 index 000000000..5ceb6b738 --- /dev/null +++ b/core/modules/catalog/helpers/associatedProducts/getBundleProductPrice.ts @@ -0,0 +1,15 @@ +import { + getBundleOptionsValues, + getBundleOptionPrice, + getSelectedBundleOptions +} from '@vue-storefront/core/modules/catalog/helpers/bundleOptions' +import Product from '@vue-storefront/core/modules/catalog/types/Product'; + +export default function getBundleProductPrice (product: Product) { + const selectedBundleOptions = getSelectedBundleOptions(product) + const { price, priceInclTax } = getBundleOptionPrice( + getBundleOptionsValues(selectedBundleOptions, product.bundle_options) + ) + + return { price, priceInclTax } +} diff --git a/core/modules/catalog/helpers/associatedProducts/getGroupedProductPrice.ts b/core/modules/catalog/helpers/associatedProducts/getGroupedProductPrice.ts new file mode 100644 index 000000000..ac50a373f --- /dev/null +++ b/core/modules/catalog/helpers/associatedProducts/getGroupedProductPrice.ts @@ -0,0 +1,8 @@ +import Product, { ProductLink } from '@vue-storefront/core/modules/catalog/types/Product'; +import { getProductLinkPrice } from './getProductLinkPrice'; + +export default function getGroupedProductPrice (product: Product) { + const productLinks: ProductLink[] = (product.product_links || []) + + return getProductLinkPrice(productLinks) +} diff --git a/core/modules/catalog/helpers/associatedProducts/getProductLinkPrice.ts b/core/modules/catalog/helpers/associatedProducts/getProductLinkPrice.ts new file mode 100644 index 000000000..34339f690 --- /dev/null +++ b/core/modules/catalog/helpers/associatedProducts/getProductLinkPrice.ts @@ -0,0 +1,36 @@ +import Product from '@vue-storefront/core/modules/catalog/types/Product'; + +interface BaseProductLink { + product?: Product, + qty?: number +} + +export const calculateProductLinkPrice = ({ price = 1, priceInclTax = 1, qty = 1 }) => { + const product = { + price: 0, + priceInclTax: 0 + } + const qtyNum = typeof qty === 'string' ? parseInt(qty) : qty + if (qtyNum >= 0) { + product.price += price * qtyNum + product.priceInclTax += priceInclTax * qtyNum + } + return product +} + +export const getProductLinkPrice = (productLinks: BaseProductLink[]) => productLinks + .map((productLink) => { + const product = productLink.product || { price: 1, price_incl_tax: 1, priceInclTax: 1 } + return calculateProductLinkPrice({ + price: product.price, + priceInclTax: product.price_incl_tax || product.priceInclTax, + qty: productLink.qty + }) + }) + .reduce( + (priceDelta, currentPriceDelta) => ({ + price: currentPriceDelta.price + priceDelta.price, + priceInclTax: currentPriceDelta.priceInclTax + priceDelta.priceInclTax + }), + { price: 0, priceInclTax: 0 } + ) diff --git a/core/modules/catalog/helpers/associatedProducts/index.ts b/core/modules/catalog/helpers/associatedProducts/index.ts new file mode 100644 index 000000000..eaaac8392 --- /dev/null +++ b/core/modules/catalog/helpers/associatedProducts/index.ts @@ -0,0 +1,7 @@ +import setGroupedProduct from './setGroupedProduct' +import setBundleProducts from './setBundleProducts' + +export { + setGroupedProduct, + setBundleProducts +} diff --git a/core/modules/catalog/helpers/associatedProducts/setBundleProducts.ts b/core/modules/catalog/helpers/associatedProducts/setBundleProducts.ts new file mode 100644 index 000000000..1a0858add --- /dev/null +++ b/core/modules/catalog/helpers/associatedProducts/setBundleProducts.ts @@ -0,0 +1,45 @@ +import Product from '@vue-storefront/core/modules/catalog/types/Product'; +import { isBundleProduct } from './..'; +import buildQuery from './buildQuery' +import setProductLink from './setProductLink' +import { ProductService } from '@vue-storefront/core/data-resolver/ProductService' +import getBundleProductPrice from './getBundleProductPrice' + +/** + * This function prepare all product_links for bundle products. + * It fetches products by sku. + */ +export default async function setBundleProducts (product: Product, { includeFields = null, excludeFields = null } = {}) { + if (isBundleProduct(product) && product.bundle_options) { + const skus = product.bundle_options + .map((bundleOption) => bundleOption.product_links.map((productLink) => productLink.sku)) + .reduce((acc, next) => acc.concat(next), []) + + const query = buildQuery(skus) + const { items } = await ProductService.getProducts({ + query, + excludeFields, + includeFields, + options: { + prefetchGroupProducts: false, + fallbackToDefaultWhenNoAvailable: false, + setProductErrors: false, + setConfigurableProductOptions: false, + assignProductConfiguration: false, + separateSelectedVariant: false + } + }) + + for (const bundleOption of product.bundle_options) { + for (const productLink of bundleOption.product_links) { + const associatedProduct = items.find((associatedProduct) => associatedProduct.sku === productLink.sku) + setProductLink(productLink, associatedProduct) + } + } + + const { price, priceInclTax } = getBundleProductPrice(product) + product.price = price + product.priceInclTax = priceInclTax + product.price_incl_tax = priceInclTax + } +} diff --git a/core/modules/catalog/helpers/associatedProducts/setGroupedProduct.ts b/core/modules/catalog/helpers/associatedProducts/setGroupedProduct.ts new file mode 100644 index 000000000..af48700d3 --- /dev/null +++ b/core/modules/catalog/helpers/associatedProducts/setGroupedProduct.ts @@ -0,0 +1,42 @@ +import Product from '@vue-storefront/core/modules/catalog/types/Product'; +import { isGroupedProduct } from './..'; +import buildQuery from './buildQuery' +import setProductLink from './setProductLink' +import { ProductService } from '@vue-storefront/core/data-resolver/ProductService' +import getGroupedProductPrice from './getGroupedProductPrice' + +/** + * This function prepare all product_links for grouped products. + * It fetches products by sku. + */ +export default async function setGroupedProduct (product: Product, { includeFields = null, excludeFields = null } = {}) { + if (isGroupedProduct(product) && product.product_links) { + const productLinks = product.product_links.filter((productLink) => productLink.link_type === 'associated' && productLink.linked_product_type === 'simple') + const skus = productLinks.map((productLink) => productLink.linked_product_sku) + + const query = buildQuery(skus) + const { items } = await ProductService.getProducts({ + query, + excludeFields, + includeFields, + options: { + prefetchGroupProducts: false, + fallbackToDefaultWhenNoAvailable: false, + setProductErrors: false, + setConfigurableProductOptions: false, + assignProductConfiguration: false, + separateSelectedVariant: false + } + }) + + for (const productLink of productLinks) { + const associatedProduct = items.find((associatedProduct) => associatedProduct.sku === productLink.linked_product_sku) + setProductLink(productLink, associatedProduct) + } + + const { price, priceInclTax } = getGroupedProductPrice(product) + product.price = price + product.priceInclTax = priceInclTax + product.price_incl_tax = priceInclTax + } +} diff --git a/core/modules/catalog/helpers/associatedProducts/setProductLink.ts b/core/modules/catalog/helpers/associatedProducts/setProductLink.ts new file mode 100644 index 000000000..bb9b0da7f --- /dev/null +++ b/core/modules/catalog/helpers/associatedProducts/setProductLink.ts @@ -0,0 +1,17 @@ +import Product, { ProductLink } from '@vue-storefront/core/modules/catalog/types/Product'; +import { preConfigureProduct } from '../prepare'; +import { Logger } from '@vue-storefront/core/lib/logger'; +import { BundleOptionsProductLink } from '@vue-storefront/core/modules/catalog/types/BundleOption'; + +/** + * Set associated product to product link object + */ + +export default async function setProductLink (productLink: BundleOptionsProductLink | ProductLink, associatedProduct: Product) { + if (associatedProduct) { + productLink.product = preConfigureProduct(associatedProduct) + productLink.product.qty = Number((productLink as BundleOptionsProductLink).qty || '1') + } else { + Logger.error('Product not found', (productLink as ProductLink).linked_product_sku || productLink.sku)() + } +} diff --git a/core/modules/catalog/helpers/bundleOptions.ts b/core/modules/catalog/helpers/bundleOptions.ts index 51234dc88..b54561390 100644 --- a/core/modules/catalog/helpers/bundleOptions.ts +++ b/core/modules/catalog/helpers/bundleOptions.ts @@ -1,39 +1,35 @@ +import { getProductLinkPrice } from './associatedProducts/getProductLinkPrice'; import get from 'lodash-es/get' +import Product from '@vue-storefront/core/modules/catalog/types/Product'; +import { BundleOption, BundleOptionsProductLink, SelectedBundleOption } from '@vue-storefront/core/modules/catalog/types/BundleOption'; -const calculateBundleOptionProductPrice = ({ price = 1, priceInclTax = 1, qty = '1' }) => { - const product = { - price: 0, - priceInclTax: 0 - } - if (parseInt(qty) >= 0) { - product.price += price * parseInt(qty) - product.priceInclTax += priceInclTax * parseInt(qty) - } - return product -} - -export const getBundleOptionPrice = (bundleOptionValues) => bundleOptionValues - .map(bundleOptionValue => { - const product = get(bundleOptionValue, 'product', {}) - return calculateBundleOptionProductPrice({ - price: product.price, - priceInclTax: product.price_incl_tax || product.priceInclTax, - qty: bundleOptionValue.qty - }) - }) - .reduce( - (priceDelta, currentPriceDelta) => ({ - price: currentPriceDelta.price + priceDelta.price, - priceInclTax: currentPriceDelta.priceInclTax + priceDelta.priceInclTax - }), - { price: 0, priceInclTax: 0 } - ) +export const getBundleOptionPrice = (bundleOptionValues: BundleOptionsProductLink[]) => getProductLinkPrice(bundleOptionValues) -export const getBundleOptionsValues = (selectedBundleOptions, allBundeOptions) => selectedBundleOptions +export const getBundleOptionsValues = (selectedBundleOptions: SelectedBundleOption[], allBundeOptions: BundleOption[]): BundleOptionsProductLink[] => selectedBundleOptions .map(selectedBundleOption => { const { product_links: productLinks = [] } = allBundeOptions.find(bundleOption => bundleOption.option_id === selectedBundleOption.option_id) || {} - const value = productLinks.find(productLink => String(productLink.id) === String(selectedBundleOption.option_selections[0])) || {} + const value = productLinks.find(productLink => String(productLink.id) === String(selectedBundleOption.option_selections[0])) return { ...value, qty: selectedBundleOption.option_qty } }) + +export const getSelectedBundleOptions = (product: Product): SelectedBundleOption[] => { + const selectedBundleOptions = Object.values(get(product, 'product_option.extension_attributes.bundle_options', {})) + if (selectedBundleOptions.length) { + return selectedBundleOptions as any as SelectedBundleOption[] + } + + // get default options + const allBundeOptions = product.bundle_options || [] + return allBundeOptions.map((bundleOption) => { + const productLinks = bundleOption.product_links || [] + const defaultLink = productLinks.find((productLink) => productLink.is_default) || productLinks[0] + const qty = (typeof defaultLink.qty === 'string' ? parseInt(defaultLink.qty) : defaultLink.qty) || 1 + return { + option_id: bundleOption.option_id, + option_qty: qty, + option_selections: [Number(defaultLink.id)] + } + }) +} diff --git a/core/modules/catalog/helpers/configure/configureProductAsync.ts b/core/modules/catalog/helpers/configure/configureProductAsync.ts new file mode 100644 index 000000000..45032161e --- /dev/null +++ b/core/modules/catalog/helpers/configure/configureProductAsync.ts @@ -0,0 +1,93 @@ +import Product from '@vue-storefront/core/modules/catalog/types/Product'; +import cloneDeep from 'lodash/cloneDeep' +import { getSelectedVariant, omitSelectedVariantFields } from '../variant'; +import { getProductConfiguration, setProductConfigurableOptions } from '../productOptions'; +import { filterOutUnavailableVariants } from '../stock'; +import { setGroupedProduct, setBundleProducts } from '../associatedProducts'; +import { hasConfigurableChildren } from './..' + +interface ConfigureProductAsyncParams { + product: Product, + configuration: any, + attribute: any, + options?: { + fallbackToDefaultWhenNoAvailable?: boolean, + setProductErrors?: boolean, + setConfigurableProductOptions?: boolean, + filterUnavailableVariants?: boolean, + assignProductConfiguration?: boolean, + separateSelectedVariant?: boolean, + prefetchGroupProducts?: boolean + }, + stockItems: any[], + excludeFields?: string[], + includeFields?: string[] +} + +/** + * This function configure product for 'configurable', 'bundle' or 'group' product. + */ +export default async function configureProductAsync ({ + product, + configuration, + attribute, + options: { + fallbackToDefaultWhenNoAvailable = true, + setProductErrors = true, + setConfigurableProductOptions = true, + filterUnavailableVariants = false, + assignProductConfiguration = false, + separateSelectedVariant = false, + prefetchGroupProducts = false + } = {}, + stockItems = [], + excludeFields, + includeFields +}: ConfigureProductAsyncParams) { + // it not only filter variants but also it apply stock object + if (filterUnavailableVariants) { + filterOutUnavailableVariants(product, stockItems) + } + + // setup bundle or group product. Product is filled with productLinks + if (prefetchGroupProducts) { + await setGroupedProduct(product, { includeFields, excludeFields }) + await setBundleProducts(product, { includeFields, excludeFields }) + } + + // setup configurable product + if (hasConfigurableChildren(product)) { + // we don't want to modify configuration object + let _configuration = cloneDeep(configuration) + + // find selected variant by configuration + let selectedVariant = getSelectedVariant(product, _configuration, { fallbackToDefaultWhenNoAvailable }) + + if (selectedVariant) { + // if there is selectedVariant we want to get configuration based on that variant + _configuration = getProductConfiguration({ product, selectedVariant, attribute }) + + // here we add product_options with selected configuration. It only applies to configurable product + setProductConfigurableOptions({ product, configuration: _configuration, setConfigurableProductOptions }) // set the custom options + + product.is_configured = true + + // remove props from variant that we don't want need to override in base product + selectedVariant = omitSelectedVariantFields(selectedVariant) + } + if (!selectedVariant && setProductErrors) { // can not find variant anyway, even the default one + product.errors.variants = 'No available product variants' + } + + const configuredProduct = { + ...product, + ...(assignProductConfiguration ? { configuration: _configuration } : {}) // we can need configuration as separate object + } + return { + ...configuredProduct, + ...(separateSelectedVariant ? { selectedVariant } : selectedVariant) // we can need selected variant as separate object + } + } + + return product +} diff --git a/core/modules/catalog/helpers/configure/configureProducts.ts b/core/modules/catalog/helpers/configure/configureProducts.ts new file mode 100644 index 000000000..f8d0589a4 --- /dev/null +++ b/core/modules/catalog/helpers/configure/configureProducts.ts @@ -0,0 +1,59 @@ +import { AttributesMetadata } from './../../types/Attribute'; +import { getStockItems } from '../stock'; +import Product from '@vue-storefront/core/modules/catalog/types/Product'; +import transformMetadataToAttributes from '../transformMetadataToAttributes'; +import configureProductAsync from './configureProductAsync'; + +interface ConfigureProductsParams { + products: Product[], + attributes_metadata: AttributesMetadata[], + configuration: any, + options?: { + fallbackToDefaultWhenNoAvailable?: boolean, + setProductErrors?: boolean, + setConfigurableProductOptions?: boolean, + filterUnavailableVariants?: boolean, + assignProductConfiguration?: boolean, + separateSelectedVariant?: boolean, + prefetchGroupProducts?: boolean + }, + excludeFields?: string[], + includeFields?: string[] +} + +/** + * Prepare all data needed to make product configuration. + * After common data is setup this function map through every product and configure it based on 'configuration' object + */ +export default async function configureProducts ({ + products, + attributes_metadata = [], + configuration = {}, + options = {}, + excludeFields = null, + includeFields = null +}: ConfigureProductsParams) { + const productAttributesMetadata = products.map((product) => product.attributes_metadata || []) + const attribute = transformMetadataToAttributes([attributes_metadata, ...productAttributesMetadata]) + const attributeStateFormat = { list_by_code: attribute.attrHashByCode, list_by_id: attribute.attrHashById } + + let stockItems = [] + if (options.filterUnavailableVariants) { + stockItems = await getStockItems(products) + } + + const configuredProducts = await Promise.all((products as Product[]).map(async (product) => { + const configuredProduct = await configureProductAsync({ + product, + configuration, + attribute: attributeStateFormat, + options: options, + stockItems, + excludeFields, + includeFields + }) + return configuredProduct as Product + })) + + return configuredProducts +} diff --git a/core/modules/catalog/helpers/configure/index.ts b/core/modules/catalog/helpers/configure/index.ts new file mode 100644 index 000000000..258ce9637 --- /dev/null +++ b/core/modules/catalog/helpers/configure/index.ts @@ -0,0 +1,5 @@ +import configureProducts from './configureProducts' + +export { + configureProducts +} diff --git a/core/modules/catalog/helpers/createAttributesListQuery.ts b/core/modules/catalog/helpers/createAttributesListQuery.ts index dedeabc16..7883c6c7d 100644 --- a/core/modules/catalog/helpers/createAttributesListQuery.ts +++ b/core/modules/catalog/helpers/createAttributesListQuery.ts @@ -1,4 +1,4 @@ -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' +import { SearchQuery } from 'storefront-query-builder' const createAttributesListQuery = ({ filterValues, diff --git a/core/modules/catalog/helpers/createCategoryListQuery.ts b/core/modules/catalog/helpers/createCategoryListQuery.ts index 0ba89ff8e..5e6db47db 100644 --- a/core/modules/catalog/helpers/createCategoryListQuery.ts +++ b/core/modules/catalog/helpers/createCategoryListQuery.ts @@ -1,4 +1,4 @@ -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' +import { SearchQuery } from 'storefront-query-builder' import { isServer } from '@vue-storefront/core/helpers' import config from 'config' diff --git a/core/modules/catalog/helpers/customOption.ts b/core/modules/catalog/helpers/customOption.ts index 23a007ec5..2ff7a837b 100644 --- a/core/modules/catalog/helpers/customOption.ts +++ b/core/modules/catalog/helpers/customOption.ts @@ -60,7 +60,7 @@ export const getCustomOptionValues = (selectedCustomOptions: SelectedCustomOptio }) .reduce((allCustomOptionValues, customOptionValue) => allCustomOptionValues.concat(customOptionValue), []) // merge all values in one array -export const getCustomOptionPriceDelta = (customOptionValues: OptionValue[], { price, priceInclTax }: Pick) => customOptionValues +export const getCustomOptionPriceDelta = (customOptionValues: OptionValue[], { price, priceInclTax, price_incl_tax }: Pick) => customOptionValues .reduce((delta, customOptionValue) => { if (customOptionValue.price_type === 'fixed' && customOptionValue.price !== 0) { delta.price += customOptionValue.price @@ -68,7 +68,7 @@ export const getCustomOptionPriceDelta = (customOptionValues: OptionValue[], { p } if (customOptionValue.price_type === 'percent' && customOptionValue.price !== 0) { delta.price += ((customOptionValue.price / 100) * price) - delta.priceInclTax += ((customOptionValue.price / 100) * priceInclTax) + delta.priceInclTax += ((customOptionValue.price / 100) * (priceInclTax || price_incl_tax)) } return delta }, { diff --git a/core/modules/catalog/helpers/deprecatedHelpers.ts b/core/modules/catalog/helpers/deprecatedHelpers.ts new file mode 100644 index 000000000..2917769e9 --- /dev/null +++ b/core/modules/catalog/helpers/deprecatedHelpers.ts @@ -0,0 +1,145 @@ +import Vue from 'vue' +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' +import omit from 'lodash-es/omit' +import i18n from '@vue-storefront/i18n' +import { Logger } from '@vue-storefront/core/lib/logger' +import { optionLabel } from './optionLabel' +import { setProductConfigurableOptions } from './productOptions' +import config from 'config' +import { findConfigurableVariant } from './variant' +import { hasImage } from './' + +export function populateProductConfigurationAsync (context, { product, selectedVariant }) { + Logger.warn('deprecated, will be not used from 1.12')() + if (product.configurable_options) { + for (let option of product.configurable_options) { + let attribute_code + let attribute_label + if (option.attribute_code) { + attribute_code = option.attribute_code + attribute_label = option.label ? option.label : (option.frontend_label ? option.frontend_label : option.default_frontend_label) + } else { + if (option.attribute_id) { + let attr = context.rootState.attribute.list_by_id[option.attribute_id] + if (!attr) { + Logger.error('Wrong attribute given in configurable_options - can not find by attribute_id', option)() + continue + } else { + attribute_code = attr.attribute_code + attribute_label = attr.frontend_label ? attr.frontend_label : attr.default_frontend_label + } + } else { + Logger.error('Wrong attribute given in configurable_options - no attribute_code / attribute_id', option)() + } + } + let selectedOption = null + if (selectedVariant.custom_attributes) { + selectedOption = selectedVariant.custom_attributes.find((a) => { // this is without the "label" + return (a.attribute_code === attribute_code) + }) + } else { + selectedOption = { + attribute_code: attribute_code, + value: selectedVariant[attribute_code] + } + } + if (option.values && option.values.length) { + const selectedOptionMeta = option.values.find(ov => { return ov.value_index === selectedOption.value }) + if (selectedOptionMeta) { + selectedOption.label = selectedOptionMeta.label ? selectedOptionMeta.label : selectedOptionMeta.default_label + selectedOption.value_data = selectedOptionMeta.value_data + } + } + + const confVal = { + attribute_code: attribute_code, + id: selectedOption.value, + label: selectedOption.label ? selectedOption.label : /* if not set - find by attribute */optionLabel(context.rootState.attribute, { attributeKey: selectedOption.attribute_code, searchBy: 'code', optionId: selectedOption.value }) + } + Vue.set(context.state.current_configuration, attribute_code, confVal) + } + setProductConfigurableOptions({ + product, + configuration: context.state.current_configuration, + setConfigurableProductOptions: config.cart.setConfigurableProductOptions + }) // set the custom options + } + return selectedVariant +} + +export function configureProductAsync (context, { product, configuration, selectDefaultVariant = true, fallbackToDefaultWhenNoAvailable = true, setProductErorrs = false }) { + Logger.warn('deprecated, will be not used from 1.12')() + // use current product if product wasn't passed + if (product === null) product = context.getters.getCurrentProduct + const hasConfigurableChildren = (product.configurable_children && product.configurable_children.length > 0) + + if (hasConfigurableChildren) { + // handle custom_attributes for easier comparing in the future + product.configurable_children.forEach((child) => { + let customAttributesAsObject = {} + if (child.custom_attributes) { + child.custom_attributes.forEach((attr) => { + customAttributesAsObject[attr.attribute_code] = attr.value + }) + // add values from custom_attributes in a different form + Object.assign(child, customAttributesAsObject) + } + }) + // find selected variant + let desiredProductFound = false + let selectedVariant = findConfigurableVariant({ product, configuration, availabilityCheck: true }) + if (!selectedVariant) { + if (fallbackToDefaultWhenNoAvailable) { + selectedVariant = findConfigurableVariant({ product, selectDefaultChildren: true, availabilityCheck: true }) // return first available child + desiredProductFound = false + } else { + desiredProductFound = false + } + } else { + desiredProductFound = true + } + + if (selectedVariant) { + if (!desiredProductFound && selectDefaultVariant /** don't change the state when no selectDefaultVariant is set */) { // update the configuration + populateProductConfigurationAsync(context, { product: product, selectedVariant: selectedVariant }) + configuration = context.state.current_configuration + } + if (setProductErorrs) { + product.errors = {} // clear the product errors + } + product.is_configured = true + + if (config.cart.setConfigurableProductOptions && !selectDefaultVariant && !(Object.keys(configuration).length === 1 && configuration.sku)) { + // the condition above: if selectDefaultVariant - then "setCurrent" is seeting the configurable options; if configuration = { sku: '' } -> this is a special case when not configuring the product but just searching by sku + setProductConfigurableOptions({ + product, + configuration, + setConfigurableProductOptions: config.cart.setConfigurableProductOptions + }) // set the custom options + }/* else { + Logger.debug('Skipping configurable options setup', configuration)() + } */ + const fieldsToOmit = ['name'] + if (!hasImage(selectedVariant)) fieldsToOmit.push('image') + selectedVariant = omit(selectedVariant, fieldsToOmit) // We need to send the parent SKU to the Magento cart sync but use the child SKU internally in this case + // use chosen variant for the current product + if (selectDefaultVariant) { + context.dispatch('setCurrent', selectedVariant) + } + EventBus.$emit('product-after-configure', { product: product, configuration: configuration, selectedVariant: selectedVariant }) + } + if (!selectedVariant && setProductErorrs) { // can not find variant anyway, even the default one + product.errors.variants = i18n.t('No available product variants') + if (selectDefaultVariant) { + context.dispatch('setCurrent', product) // without the configuration + } + } + return selectedVariant + } else { + if (fallbackToDefaultWhenNoAvailable) { + return product + } else { + return null + } + } +} diff --git a/core/modules/catalog/helpers/getProductGallery.ts b/core/modules/catalog/helpers/getProductGallery.ts new file mode 100644 index 000000000..87709f7f2 --- /dev/null +++ b/core/modules/catalog/helpers/getProductGallery.ts @@ -0,0 +1,21 @@ +import config from 'config' +import { + getMediaGallery, + configurableChildrenImages, + attributeImages +} from './' +import uniqBy from 'lodash-es/uniqBy' +import Product from '@vue-storefront/core/modules/catalog/types/Product'; + +export default function getProductGallery (product: Product) { + if (product.type_id === 'configurable' && product.hasOwnProperty('configurable_children')) { + if (!config.products.gallery.mergeConfigurableChildren && product.is_configured) { + return attributeImages(product) + } + } + + const productGallery = uniqBy(configurableChildrenImages(product).concat(getMediaGallery(product)), 'src') + .filter(f => f.src && f.src !== config.images.productPlaceholder) + + return productGallery +} diff --git a/core/modules/catalog/helpers/index.ts b/core/modules/catalog/helpers/index.ts index 57e5c37c7..4510c56e7 100644 --- a/core/modules/catalog/helpers/index.ts +++ b/core/modules/catalog/helpers/index.ts @@ -1,31 +1,37 @@ -import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' -import rootStore from '@vue-storefront/core/store' -import flattenDeep from 'lodash-es/flattenDeep' -import omit from 'lodash-es/omit' -import remove from 'lodash-es/remove' -import toString from 'lodash-es/toString' -import union from 'lodash-es/union' +import Vue from 'vue' // TODO: Remove this dependency import { optionLabel } from './optionLabel' -import i18n from '@vue-storefront/i18n' import { getThumbnailPath } from '@vue-storefront/core/helpers' -import { Logger } from '@vue-storefront/core/lib/logger' -import { isServer } from '@vue-storefront/core/helpers' import config from 'config' - -function _filterRootProductByStockitem (context, stockItem, product, errorCallback) { - if (stockItem) { - product.stock = stockItem - if (stockItem.is_in_stock === false) { - product.errors.variants = i18n.t('No available product variants') - context.state.current.errors = product.errors - EventBus.$emit('product-after-removevariant', { product: product }) - if (config.products.listOutOfStockProducts === false) { - errorCallback(new Error('Product query returned an empty result')) - } - } - } -} +import registerProductsMapping from './registerProductsMapping' +import getProductGallery from './getProductGallery' +import { findConfigurableVariant, isOptionAvailable } from './variant' +import { filterOutUnavailableVariants } from './stock' +import { doPlatformPricesSync } from './price' +import { setProductConfigurableOptions } from './productOptions' + +/** Below helpers are not used from 1.12 and can be removed to reduce bundle */ +import { populateProductConfigurationAsync, configureProductAsync } from './deprecatedHelpers' +export { + populateProductConfigurationAsync, + configureProductAsync +} +/***/ + +export { + registerProductsMapping, + getProductGallery, + optionLabel, + findConfigurableVariant as findConfigurableChildAsync, + isOptionAvailable as isOptionAvailableAsync, + filterOutUnavailableVariants, + doPlatformPricesSync, + setProductConfigurableOptions as setConfigurableProductOptionsAsync +} + +export const hasConfigurableChildren = (product) => product && product.configurable_children && product.configurable_children.length +export const isGroupedProduct = (product) => product.type_id === 'grouped' +export const isBundleProduct = (product) => product.type_id === 'bundle' /** * check if object have an image @@ -36,293 +42,6 @@ export const hasImage = (product) => product && product.image && product.image ! */ export const childHasImage = (children = []) => children.some(hasImage) -export function findConfigurableChildAsync ({ product, configuration = null, selectDefaultChildren = false, availabilityCheck = true }) { - let regularProductPrice = product.original_price_incl_tax ? product.original_price_incl_tax : product.price_incl_tax - let selectedVariant = product.configurable_children.find((configurableChild) => { - if (availabilityCheck) { - if (configurableChild.stock && !config.products.listOutOfStockProducts) { - if (!configurableChild.stock.is_in_stock) { - return false - } - } - } - if (configurableChild.status >= 2/** disabled product */) { - return false - } - if (selectDefaultChildren) { - return true // return first - } - if (configuration.sku) { - return configurableChild.sku === configuration.sku // by sku or first one - } else { - if (!configuration || (configuration && Object.keys(configuration).length === 0)) { // no configuration - return the first child cheaper than the original price - if found - if (configurableChild.price_incl_tax <= regularProductPrice) { - return true - } - } else { - return Object.keys(omit(configuration, ['price'])).every((configProperty) => { - let configurationPropertyFilters = configuration[configProperty] || [] - if (!Array.isArray(configurationPropertyFilters)) configurationPropertyFilters = [configurationPropertyFilters] - const configurationIds = configurationPropertyFilters.map(filter => toString(filter.id)).filter(filterId => !!filterId) - if (!configurationIds.length) return true // skip empty - return configurationIds.includes(toString(configurableChild[configProperty])) - }) - } - } - }) - return selectedVariant -} - -export function isOptionAvailableAsync (context, { product, configuration }) { - const variant = findConfigurableChildAsync({ product: product, configuration: configuration, availabilityCheck: true }) - return typeof variant !== 'undefined' && variant !== null -} - -function _filterChildrenByStockitem (context, stockItems, product, diffLog) { - if (config.products.filterUnavailableVariants) { - if (product.type_id === 'configurable' && product.configurable_children) { - for (const stockItem of stockItems) { - const confChild = product.configurable_children.find(p => { return p.id === stockItem.product_id }) - if (stockItem.is_in_stock === false || (confChild && confChild.status >= 2/* conf child is disabled */)) { - product.configurable_children = product.configurable_children.filter((p) => { return p.id !== stockItem.product_id }) - diffLog.push(stockItem.product_id) - } else { - if (confChild) { - confChild.stock = stockItem - } - } - } - let totalOptions = 0 - let removedOptions = 0 - for (const optionKey in context.state.current_options) { - let optionsAvailable = context.state.current_options[optionKey] // TODO: it should take the attribute combinations into consideration - if (optionsAvailable && optionsAvailable.length > 0) { - optionsAvailable = optionsAvailable.filter((opt) => { - const config = {} - config[optionKey] = opt - const variant = isOptionAvailableAsync(context, { product: product, configuration: config }) - if (!variant) { - Logger.log('No variant for' + opt, 'helper')() - EventBus.$emit('product-after-removevariant', { product: product }) - removedOptions++ - return false - } else { - totalOptions++ - return true - } - }) - Logger.debug('Options still available' + optionsAvailable + removedOptions, 'helper')() - context.state.current_options[optionKey] = optionsAvailable - } - } - // eslint-disable-next-line @typescript-eslint/no-use-before-define - configureProductAsync(context, { product, configuration: context.state.current_configuration, selectDefaultVariant: true, fallbackToDefaultWhenNoAvailable: true }) - if (totalOptions === 0) { - product.errors.variants = i18n.t('No available product variants') - context.state.current.errors = product.errors - EventBus.$emit('product-after-removevariant', { product: product }) - } - } - } -} - -export function filterOutUnavailableVariants (context, product) { - return new Promise((resolve, reject) => { - if (config.products.filterUnavailableVariants) { - const _filterConfigurableHelper = () => { - if (product.type_id === 'configurable' && product.configurable_children) { - const stockItems = [] - let confChildSkus = product.configurable_children.map((c) => { return c.sku }) - for (const confChild of product.configurable_children) { - const stockCached = context.rootState.stock.cache[confChild.id] - if (stockCached) { - stockItems.push(stockCached) - confChildSkus = remove(confChildSkus, (skuToCheck) => skuToCheck === confChild.sku) - } - } - Logger.debug('Cached stock items and delta' + stockItems + confChildSkus)() - if (confChildSkus.length > 0) { - context.dispatch('stock/list', { skus: confChildSkus }, { root: true }).then((task) => { - if (task && task.resultCode === 200) { - const diffLog = [] - _filterChildrenByStockitem(context, union(task.result, stockItems), product, diffLog) - Logger.debug('Filtered configurable_children with the network call' + diffLog, 'helper')() - resolve() - } else { - Logger.error('Cannot sync the availability of the product options. Please update the vue-storefront-api or switch on the Internet', 'helper')() - } - }).catch(err => { - Logger.error(err, 'helper')() - }) - } else { - const diffLog = [] - _filterChildrenByStockitem(context, stockItems, product, diffLog) - Logger.debug('Filtered configurable_children without the network call' + diffLog, 'helper')() - resolve() - } - } else { - resolve() - } - } - const rootStockCached = context.rootState.stock.cache[product.id] - if (!rootStockCached) { - context.dispatch('stock/list', { skus: [product.sku] }, { root: true }).then((task) => { - _filterRootProductByStockitem(context, task && task.result && task.result.length ? task.result[0] : null, product, reject) - Logger.debug('Filtered root product stock with the network call')() - _filterConfigurableHelper() - }) - } else { - _filterRootProductByStockitem(context, rootStockCached, product, reject) - Logger.debug('Filtered root product stock without the network call')() - _filterConfigurableHelper() - } - } else { - resolve() - } - }) -} - -export function syncProductPrice (product, backProduct) { // TODO: we probably need to update the Net prices here as well - product.sgn = backProduct.sgn // copy the signature for the modified price - product.price_incl_tax = backProduct.price_info.final_price - product.original_price_incl_tax = backProduct.price_info.regular_price - product.special_price_incl_tax = backProduct.price_info.special_price - - product.special_price = backProduct.price_info.extension_attributes.tax_adjustments.special_price - product.price = backProduct.price_info.extension_attributes.tax_adjustments.final_price - product.original_price = backProduct.price_info.extension_attributes.tax_adjustments.regular_price - - product.price_tax = product.price_incl_tax - product.price - product.special_price_tax = product.special_price_incl_tax - product.special_price - product.original_price_tax = product.original_price_incl_tax - product.original_trice - - if (product.price_incl_tax >= product.original_price_incl_tax) { - product.special_price_incl_tax = 0 - product.special_price = 0 - } - - /** BEGIN @deprecated - inconsitent naming kept just for the backward compatibility */ - product.priceInclTax = product.price_incl_tax - product.priceTax = product.price_tax - product.originalPrice = product.original_price - product.originalPriceInclTax = product.original_price_incl_tax - product.originalPriceTax = product.original_price_tax - product.specialPriceInclTax = product.special_price_incl_tax - product.specialPriceTax = product.special_price_tax - /** END */ - EventBus.$emit('product-after-priceupdate', product) - // Logger.log(product.sku, product, backProduct)() - return product -} -/** - * Synchronize / override prices got from ElasticSearch with current one's from Magento2 or other platform backend - * @param {Array} products - */ -export function doPlatformPricesSync (products) { - return new Promise((resolve, reject) => { - if (config.products.alwaysSyncPlatformPricesOver) { - if (config.products.clearPricesBeforePlatformSync) { - for (let product of products) { // clear out the prices as we need to sync them with Magento - product.price_incl_tax = null - product.original_price_incl_tax = null - product.special_price_incl_tax = null - - product.special_price = null - product.price = null - product.original_price = null - - product.price_tax = null - product.special_price_tax = null - product.original_price_tax = null - - /** BEGIN @deprecated - inconsitent naming kept just for the backward compatibility */ - product.priceInclTax = product.price_incl_tax - product.priceTax = product.price_tax - product.originalPrice = product.original_price - product.originalPriceInclTax = product.original_price_incl_tax - product.originalPriceTax = product.original_price_tax - product.specialPriceInclTax = product.special_price_incl_tax - product.specialPriceTax = product.special_price_tax - /** END */ - - if (product.configurable_children) { - for (let sc of product.configurable_children) { - sc.price_incl_tax = null - sc.original_price_incl_tax = null - sc.special_price_incl_tax = null - - sc.special_price = null - sc.price = null - sc.original_price = null - - sc.price_tax = null - sc.special_price_tax = null - sc.original_price_tax = null - - /** BEGIN @deprecated - inconsitent naming kept just for the backward compatibility */ - sc.priceInclTax = sc.price_incl_tax - sc.priceTax = sc.price_tax - sc.originalPrice = sc.original_price - sc.originalPriceInclTax = sc.original_price_incl_tax - sc.originalPriceTax = sc.original_price_tax - sc.specialPriceInclTax = sc.special_price_incl_tax - sc.specialPriceTax = sc.special_price_tax - /** END */ - } - } - } - } - - let skus = products.map((p) => { return p.sku }) - - if (products.length === 1) { // single product - download child data - const childSkus = flattenDeep(products.map((p) => { return (p.configurable_children) ? p.configurable_children.map((cc) => { return cc.sku }) : null })) - skus = union(skus, childSkus) - } - if (skus && skus.length > 0) { - Logger.log('Starting platform prices sync for', skus) // TODO: add option for syncro and non syncro return() - rootStore.dispatch('product/syncPlatformPricesOver', { skus: skus }, { root: true }).then((syncResult) => { - if (syncResult) { - syncResult = syncResult.items - - for (let product of products) { - const backProduct = syncResult.find((itm) => { return itm.id === product.id }) - if (backProduct) { - product.price_is_current = true // in case we're syncing up the prices we should mark if we do have current or not - product.price_refreshed_at = new Date() - product = syncProductPrice(product, backProduct) - - if (product.configurable_children) { - for (let configurableChild of product.configurable_children) { - const backProductChild = syncResult.find((itm) => { return itm.id === configurableChild.id }) - if (backProductChild) { - configurableChild = syncProductPrice(configurableChild, backProductChild) - } - } - } - // TODO: shall we update local storage here for the main product? - } - } - } - resolve(products) - }) - } else { // empty list of products - resolve(products) - } - if (!config.products.waitForPlatformSync && !isServer) { - Logger.log('Returning products, the prices yet to come from backend!')() - for (let product of products) { - product.price_is_current = false // in case we're syncing up the prices we should mark if we do have current or not - product.price_refreshed_at = null - } - resolve(products) - } - } else { - resolve(products) - } - }) -} - function _prepareProductOption (product) { let product_option = { extension_attributes: { @@ -336,45 +55,6 @@ function _prepareProductOption (product) { } */ return product_option } -export function setConfigurableProductOptionsAsync (context, { product, configuration }) { - if (product.configurable_options) { - const product_option = _prepareProductOption(product) - /* eslint camelcase: "off" */ - const configurable_item_options = product_option.extension_attributes.configurable_item_options - for (const configKey of Object.keys(configuration)) { - const configOption = configuration[configKey] - if (configOption.attribute_code && configOption.attribute_code !== 'price') { - const option = product.configurable_options.find(co => { - return (co.attribute_code === configOption.attribute_code) - }) - - if (!option) { - Logger.error('Wrong option id for setProductOptions', configOption.attribute_code)() - return null - } - let existingOption = configurable_item_options.find(cop => { - return cop.option_id === option.attribute_id - }) - if (!existingOption) { - existingOption = { - option_id: option.attribute_id, - option_value: configOption.id, - label: option.label || i18n.t(configOption.attribute_code), - value: configOption.label - } - configurable_item_options.push(existingOption) - } - existingOption.option_value = configOption.id - existingOption.label = option.label || i18n.t(configOption.attribute_code) - existingOption.value = configOption.label - } - } - // Logger.debug('Server product options object', product_option)() - return product_option - } else { - return null - } -} export function setCustomProductOptionsAsync (context, { product, customOptions }) { const productOption = _prepareProductOption(product) @@ -388,155 +68,6 @@ export function setBundleProductOptionsAsync (context, { product, bundleOptions return productOption } -function _internalMapOptions (productOption) { - const optionsMapped = [] - for (let option of productOption.extension_attributes.configurable_item_options) { - optionsMapped.push({ - label: option.label, - value: option.value - }) - } - productOption.extension_attributes.configurable_item_options = productOption.extension_attributes.configurable_item_options.map((op) => { - return omit(op, ['label', 'value']) - }) - return optionsMapped -} - -export function populateProductConfigurationAsync (context, { product, selectedVariant }) { - if (product.configurable_options) { - for (let option of product.configurable_options) { - let attribute_code - let attribute_label - if (option.attribute_code) { - attribute_code = option.attribute_code - attribute_label = option.label ? option.label : (option.frontend_label ? option.frontend_label : option.default_frontend_label) - } else { - if (option.attribute_id) { - let attr = context.rootState.attribute.list_by_id[option.attribute_id] - if (!attr) { - Logger.error('Wrong attribute given in configurable_options - can not find by attribute_id', option)() - continue - } else { - attribute_code = attr.attribute_code - attribute_label = attr.frontend_label ? attr.frontend_label : attr.default_frontend_label - } - } else { - Logger.error('Wrong attribute given in configurable_options - no attribute_code / attribute_id', option)() - } - } - let selectedOption = null - if (selectedVariant.custom_attributes) { - selectedOption = selectedVariant.custom_attributes.find((a) => { // this is without the "label" - return (a.attribute_code === attribute_code) - }) - } else { - selectedOption = { - attribute_code: attribute_code, - value: selectedVariant[attribute_code] - } - } - if (option.values && option.values.length) { - const selectedOptionMeta = option.values.find(ov => { return ov.value_index === selectedOption.value }) - if (selectedOptionMeta) { - selectedOption.label = selectedOptionMeta.label ? selectedOptionMeta.label : selectedOptionMeta.default_label - selectedOption.value_data = selectedOptionMeta.value_data - } - } - - const confVal = { - attribute_code: attribute_code, - id: selectedOption.value, - label: selectedOption.label ? selectedOption.label : /* if not set - find by attribute */optionLabel(context.rootState.attribute, { attributeKey: selectedOption.attribute_code, searchBy: 'code', optionId: selectedOption.value }) - } - context.state.current_configuration[attribute_code] = confVal - } - if (config.cart.setConfigurableProductOptions) { - const productOption = setConfigurableProductOptionsAsync(context, { product: product, configuration: context.state.current_configuration }) // set the custom options - if (productOption) { - product.options = _internalMapOptions(productOption) - product.product_option = productOption - } - } - } - return selectedVariant -} - -export function configureProductAsync (context, { product, configuration, selectDefaultVariant = true, fallbackToDefaultWhenNoAvailable = true, setProductErorrs = false }) { - // use current product if product wasn't passed - if (product === null) product = context.getters.getCurrentProduct - const hasConfigurableChildren = (product.configurable_children && product.configurable_children.length > 0) - - if (hasConfigurableChildren) { - // handle custom_attributes for easier comparing in the future - product.configurable_children.forEach((child) => { - let customAttributesAsObject = {} - if (child.custom_attributes) { - child.custom_attributes.forEach((attr) => { - customAttributesAsObject[attr.attribute_code] = attr.value - }) - // add values from custom_attributes in a different form - Object.assign(child, customAttributesAsObject) - } - }) - // find selected variant - let desiredProductFound = false - let selectedVariant = findConfigurableChildAsync({ product, configuration, availabilityCheck: true }) - if (!selectedVariant) { - if (fallbackToDefaultWhenNoAvailable) { - selectedVariant = findConfigurableChildAsync({ product, selectDefaultChildren: true, availabilityCheck: true }) // return first available child - desiredProductFound = false - } else { - desiredProductFound = false - } - } else { - desiredProductFound = true - } - - if (selectedVariant) { - if (!desiredProductFound && selectDefaultVariant /** don't change the state when no selectDefaultVariant is set */) { // update the configuration - populateProductConfigurationAsync(context, { product: product, selectedVariant: selectedVariant }) - configuration = context.state.current_configuration - } - if (setProductErorrs) { - product.errors = {} // clear the product errors - } - product.is_configured = true - - if (config.cart.setConfigurableProductOptions && !selectDefaultVariant && !(Object.keys(configuration).length === 1 && configuration.sku)) { - // the condition above: if selectDefaultVariant - then "setCurrent" is seeting the configurable options; if configuration = { sku: '' } -> this is a special case when not configuring the product but just searching by sku - const productOption = setConfigurableProductOptionsAsync(context, { product: product, configuration: configuration }) // set the custom options - if (productOption) { - selectedVariant.product_option = productOption - selectedVariant.options = _internalMapOptions(productOption) - } - }/* else { - Logger.debug('Skipping configurable options setup', configuration)() - } */ - const fieldsToOmit = ['name'] - if (!hasImage(selectedVariant)) fieldsToOmit.push('image') - selectedVariant = omit(selectedVariant, fieldsToOmit) // We need to send the parent SKU to the Magento cart sync but use the child SKU internally in this case - // use chosen variant for the current product - if (selectDefaultVariant) { - context.dispatch('setCurrent', selectedVariant) - } - EventBus.$emit('product-after-configure', { product: product, configuration: configuration, selectedVariant: selectedVariant }) - } - if (!selectedVariant && setProductErorrs) { // can not find variant anyway, even the default one - product.errors.variants = i18n.t('No available product variants') - if (selectDefaultVariant) { - context.dispatch('setCurrent', product) // without the configuration - } - } - return selectedVariant - } else { - if (fallbackToDefaultWhenNoAvailable) { - return product - } else { - return null - } - } -} - /** * Get media Gallery images from product */ @@ -607,3 +138,11 @@ export function configurableChildrenImages (product) { } return configurableChildrenImages } + +export const setRequestCacheTags = ({ products = [] }) => { + if (Vue.prototype.$cacheTags) { + products.forEach((product) => { + Vue.prototype.$cacheTags.add(`P${product.id}`); + }) + } +} diff --git a/core/modules/catalog/helpers/optionLabel.ts b/core/modules/catalog/helpers/optionLabel.ts index 12549b658..d16985515 100644 --- a/core/modules/catalog/helpers/optionLabel.ts +++ b/core/modules/catalog/helpers/optionLabel.ts @@ -5,24 +5,22 @@ * @param {String} optionId - value to get label for */ import toString from 'lodash-es/toString' +import get from 'lodash-es/get' export function optionLabel (state, { attributeKey, searchBy = 'code', optionId }) { - let attrCache = state.labels[attributeKey] + if (!state.labels) { + state.labels = {} + } + // check cached attribute + const attrCache = get(state, `labels.${attributeKey}.${optionId}`, null) if (attrCache) { - let label = attrCache[optionId] - - if (label) { - return label - } + return attrCache } + let attr = state['list_by_' + searchBy][attributeKey] if (attr) { - let opt = attr.options.find((op) => { // TODO: cache it in memory - if (toString(op.value) === toString(optionId)) { - return op - } - }) // TODO: i18n support with multi-website attribute names + let opt = attr.options.find((op) => toString(op.value) === toString(optionId)) if (opt) { if (!state.labels[attributeKey]) { diff --git a/core/modules/catalog/helpers/prepare/index.ts b/core/modules/catalog/helpers/prepare/index.ts new file mode 100644 index 000000000..62417fd11 --- /dev/null +++ b/core/modules/catalog/helpers/prepare/index.ts @@ -0,0 +1,35 @@ +import setDefaultQty from './setDefaultQty'; +import setDefaultObjects from './setDefaultObjects'; +import setParentSku from './setParentSku'; +import setParentId from './setParentId'; +import setCustomAttributesForChild from './setCustomAttributesForChild'; +import setDefaultProductOptions from './setDefaultProductOptions'; + +/** + * Apply base modification to product, after those modification we can store product in cache. + */ +function preConfigureProduct (product) { + // base product modifications + setDefaultQty(product) + setDefaultObjects(product) + setParentSku(product) + setParentId(product) + setCustomAttributesForChild(product) + setDefaultProductOptions(product) + + return product; +} + +/** + * Apply base modification to product list, after those modification we can store product in cache. + */ +function prepareProducts (products) { + const preparedProducts = products.map(preConfigureProduct) + + return preparedProducts +} + +export { + prepareProducts, + preConfigureProduct +} diff --git a/core/modules/catalog/helpers/prepare/setCustomAttributesForChild.ts b/core/modules/catalog/helpers/prepare/setCustomAttributesForChild.ts new file mode 100644 index 000000000..73ba3a7be --- /dev/null +++ b/core/modules/catalog/helpers/prepare/setCustomAttributesForChild.ts @@ -0,0 +1,19 @@ +import Product from '@vue-storefront/core/modules/catalog/types/Product'; +import { hasConfigurableChildren } from '../'; +/** + * Fill custom attributes for every configurable child + */ +export default function setCustomAttributesForChild (product: Product) { + if (!hasConfigurableChildren(product)) return + // handle custom_attributes for easier comparing in the future + product.configurable_children.forEach((child) => { + let customAttributesAsObject = {} + if (child.custom_attributes) { + child.custom_attributes.forEach((attr) => { + customAttributesAsObject[attr.attribute_code] = attr.value + }) + // add values from custom_attributes in a different form + Object.assign(child, customAttributesAsObject) + } + }) +} diff --git a/core/modules/catalog/helpers/prepare/setDefaultObjects.ts b/core/modules/catalog/helpers/prepare/setDefaultObjects.ts new file mode 100644 index 000000000..00fc289ca --- /dev/null +++ b/core/modules/catalog/helpers/prepare/setDefaultObjects.ts @@ -0,0 +1,8 @@ +import Product from '@vue-storefront/core/modules/catalog/types/Product'; +/** + * Default object that are used in vsf + */ +export default function setDefaultObjects (product: Product) { + product.errors = {}; // this is an object to store validation result for custom options and others + product.info = {}; +} diff --git a/core/modules/catalog/helpers/prepare/setDefaultProductOptions.ts b/core/modules/catalog/helpers/prepare/setDefaultProductOptions.ts new file mode 100644 index 000000000..22ef35717 --- /dev/null +++ b/core/modules/catalog/helpers/prepare/setDefaultProductOptions.ts @@ -0,0 +1,15 @@ +import Product from '@vue-storefront/core/modules/catalog/types/Product'; + +/** + * Init product_option, needed to next configuration step + */ +export default function setDefaultProductOptions (product: Product) { + if (product.product_option) return + product.product_option = { + extension_attributes: { + custom_options: [], + configurable_item_options: [], + bundle_options: [] + } + } +} diff --git a/core/modules/catalog/helpers/prepare/setDefaultQty.ts b/core/modules/catalog/helpers/prepare/setDefaultQty.ts new file mode 100644 index 000000000..bd3d27321 --- /dev/null +++ b/core/modules/catalog/helpers/prepare/setDefaultQty.ts @@ -0,0 +1,10 @@ +import Product from '@vue-storefront/core/modules/catalog/types/Product'; + +/** + * set product quantity to 1 + */ +export default function setDefaultQty (product: Product) { + if (!product.qty) { + product.qty = 1 + } +} diff --git a/core/modules/catalog/helpers/prepare/setParentId.ts b/core/modules/catalog/helpers/prepare/setParentId.ts new file mode 100644 index 000000000..169fea584 --- /dev/null +++ b/core/modules/catalog/helpers/prepare/setParentId.ts @@ -0,0 +1,10 @@ +import Product from '@vue-storefront/core/modules/catalog/types/Product'; + +/** + * set parent id, this is needed, because in configuration process we will override id by configurable_children.id + */ +export default function setParentId (product: Product) { + if (!product.parentId) { + product.parentId = product.id + } +} diff --git a/core/modules/catalog/helpers/prepare/setParentSku.ts b/core/modules/catalog/helpers/prepare/setParentSku.ts new file mode 100644 index 000000000..d0c0038c0 --- /dev/null +++ b/core/modules/catalog/helpers/prepare/setParentSku.ts @@ -0,0 +1,10 @@ +import Product from '@vue-storefront/core/modules/catalog/types/Product'; + +/** + * set parent sku, this is needed, because in configuration process we will override sku by configurable_children.sku + */ +export default function setParentSku (product: Product) { + if (!product.parentSku) { + product.parentSku = product.sku + } +} diff --git a/core/modules/catalog/helpers/price/doPlatformPricesSync.ts b/core/modules/catalog/helpers/price/doPlatformPricesSync.ts new file mode 100644 index 000000000..2c9556ad4 --- /dev/null +++ b/core/modules/catalog/helpers/price/doPlatformPricesSync.ts @@ -0,0 +1,119 @@ +import { isServer } from '@vue-storefront/core/helpers' +import config from 'config' +import flattenDeep from 'lodash-es/flattenDeep' +import union from 'lodash-es/union' +import { Logger } from '@vue-storefront/core/lib/logger' +import rootStore from '@vue-storefront/core/store' +import { ProductService } from '@vue-storefront/core/data-resolver/ProductService' +import syncProductPrice from './syncProductPrice' + +/** + * Synchronize / override prices got from ElasticSearch with current one's from Magento2 or other platform backend + * @param {Array} products + */ +export default function doPlatformPricesSync (products) { + return new Promise(async (resolve, reject) => { + if (config.products.alwaysSyncPlatformPricesOver) { + if (config.products.clearPricesBeforePlatformSync) { + for (let product of products) { // clear out the prices as we need to sync them with Magento + product.price_incl_tax = null + product.original_price_incl_tax = null + product.special_price_incl_tax = null + + product.special_price = null + product.price = null + product.original_price = null + + product.price_tax = null + product.special_price_tax = null + product.original_price_tax = null + + /** BEGIN @deprecated - inconsitent naming kept just for the backward compatibility */ + product.priceInclTax = product.price_incl_tax + product.priceTax = product.price_tax + product.originalPrice = product.original_price + product.originalPriceInclTax = product.original_price_incl_tax + product.originalPriceTax = product.original_price_tax + product.specialPriceInclTax = product.special_price_incl_tax + product.specialPriceTax = product.special_price_tax + /** END */ + + if (product.configurable_children) { + for (let sc of product.configurable_children) { + sc.price_incl_tax = null + sc.original_price_incl_tax = null + sc.special_price_incl_tax = null + + sc.special_price = null + sc.price = null + sc.original_price = null + + sc.price_tax = null + sc.special_price_tax = null + sc.original_price_tax = null + + /** BEGIN @deprecated - inconsitent naming kept just for the backward compatibility */ + sc.priceInclTax = sc.price_incl_tax + sc.priceTax = sc.price_tax + sc.originalPrice = sc.original_price + sc.originalPriceInclTax = sc.original_price_incl_tax + sc.originalPriceTax = sc.original_price_tax + sc.specialPriceInclTax = sc.special_price_incl_tax + sc.specialPriceTax = sc.special_price_tax + /** END */ + } + } + } + } + + let skus = products.map((p) => { return p.sku }) + + if (products.length === 1) { // single product - download child data + const childSkus = flattenDeep(products.map((p) => { return (p.configurable_children) ? p.configurable_children.map((cc) => { return cc.sku }) : null })) + skus = union(skus, childSkus) + } + if (skus && skus.length > 0) { + Logger.log('Starting platform prices sync for', skus) // TODO: add option for syncro and non syncro return() + const { items } = await ProductService.getProductRenderList({ + skus, + isUserGroupedTaxActive: rootStore.getters['tax/getIsUserGroupedTaxActive'], + userGroupId: rootStore.getters['tax/getUserTaxGroupId'], + token: rootStore.getters['user/getToken'] + }) + if (items) { + for (let product of products) { + const backProduct = items.find((itm) => itm.id === product.id) + if (backProduct) { + product.price_is_current = true // in case we're syncing up the prices we should mark if we do have current or not + product.price_refreshed_at = new Date() + product = syncProductPrice(product, backProduct) + + if (product.configurable_children) { + for (let configurableChild of product.configurable_children) { + const backProductChild = items.find((itm) => itm.id === configurableChild.id) + if (backProductChild) { + configurableChild = syncProductPrice(configurableChild, backProductChild) + } + } + } + // TODO: shall we update local storage here for the main product? + } + } + } + resolve(products) + } else { // empty list of products + resolve(products) + } + if (!config.products.waitForPlatformSync && !isServer) { + Logger.log('Returning products, the prices yet to come from backend!')() + for (let product of products) { + product.price_is_current = false // in case we're syncing up the prices we should mark if we do have current or not + product.price_refreshed_at = null + } + resolve(products) + } + } else { + resolve(products) + } + }) +} diff --git a/core/modules/catalog/helpers/price/index.ts b/core/modules/catalog/helpers/price/index.ts new file mode 100644 index 000000000..93dc819cc --- /dev/null +++ b/core/modules/catalog/helpers/price/index.ts @@ -0,0 +1,5 @@ +import doPlatformPricesSync from './doPlatformPricesSync' + +export { + doPlatformPricesSync +} diff --git a/core/modules/catalog/helpers/price/syncProductPrice.ts b/core/modules/catalog/helpers/price/syncProductPrice.ts new file mode 100644 index 000000000..946f660a3 --- /dev/null +++ b/core/modules/catalog/helpers/price/syncProductPrice.ts @@ -0,0 +1,34 @@ +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' + +export default function syncProductPrice (product, backProduct) { // TODO: we probably need to update the Net prices here as well + product.sgn = backProduct.sgn // copy the signature for the modified price + product.price_incl_tax = backProduct.price_info.final_price + product.original_price_incl_tax = backProduct.price_info.regular_price + product.special_price_incl_tax = backProduct.price_info.special_price + + product.special_price = backProduct.price_info.extension_attributes.tax_adjustments.special_price + product.price = backProduct.price_info.extension_attributes.tax_adjustments.final_price + product.original_price = backProduct.price_info.extension_attributes.tax_adjustments.regular_price + + product.price_tax = product.price_incl_tax - product.price + product.special_price_tax = product.special_price_incl_tax - product.special_price + product.original_price_tax = product.original_price_incl_tax - product.original_trice + + if (product.price_incl_tax >= product.original_price_incl_tax) { + product.special_price_incl_tax = 0 + product.special_price = 0 + } + + /** BEGIN @deprecated - inconsitent naming kept just for the backward compatibility */ + product.priceInclTax = product.price_incl_tax + product.priceTax = product.price_tax + product.originalPrice = product.original_price + product.originalPriceInclTax = product.original_price_incl_tax + product.originalPriceTax = product.original_price_tax + product.specialPriceInclTax = product.special_price_incl_tax + product.specialPriceTax = product.special_price_tax + /** END */ + EventBus.$emit('product-after-priceupdate', product) + // Logger.log(product.sku, product, backProduct)() + return product +} diff --git a/core/modules/catalog/helpers/productOptions/getAllProductConfigurations.ts b/core/modules/catalog/helpers/productOptions/getAllProductConfigurations.ts new file mode 100644 index 000000000..2f50e8e7f --- /dev/null +++ b/core/modules/catalog/helpers/productOptions/getAllProductConfigurations.ts @@ -0,0 +1,35 @@ +import { ProductOptions } from '@vue-storefront/core/modules/catalog/types/Product'; + +/** + * It returns all available options for configurable product + */ +export default function getAllProductConfigurations ({ configurableOptions, configuration }): ProductOptions { + const product_option: ProductOptions = { + extension_attributes: { + custom_options: [], + configurable_item_options: [], + bundle_options: [] + } + } + /* eslint camelcase: "off" */ + product_option.extension_attributes.configurable_item_options = Object.keys(configuration) + .map((key) => configuration[key]) + .filter((configOption) => + configOption && + configOption.attribute_code && + configOption.attribute_code !== 'price' + ) + .map((configOption) => ({ + configOption, + productOption: configurableOptions.find((productConfigOption) => productConfigOption.attribute_code === configOption.attribute_code) + })) + .filter(({ productOption }) => productOption) + .map(({ configOption, productOption }) => ({ + option_id: String(productOption.attribute_id), + option_value: String(configOption.id), + label: productOption.label || configOption.attribute_code, + value: configOption.label + })) + + return product_option +} diff --git a/core/modules/catalog/helpers/productOptions/getAttributeCode.ts b/core/modules/catalog/helpers/productOptions/getAttributeCode.ts new file mode 100644 index 000000000..136263095 --- /dev/null +++ b/core/modules/catalog/helpers/productOptions/getAttributeCode.ts @@ -0,0 +1,9 @@ +/** + * Returns attribute_code for product option + */ +export default function getAttributeCode (option, attribute): string { + const attribute_code = option.attribute_code + ? option.attribute_code + : option.attribute_id && (attribute.list_by_id[option.attribute_id] || {}).attribute_code + return attribute_code || option.label.toLowerCase() +} diff --git a/core/modules/catalog/helpers/productOptions/getInternalOptionsFormat.ts b/core/modules/catalog/helpers/productOptions/getInternalOptionsFormat.ts new file mode 100644 index 000000000..407e0d0a5 --- /dev/null +++ b/core/modules/catalog/helpers/productOptions/getInternalOptionsFormat.ts @@ -0,0 +1,7 @@ +/** + * Returns internal format for product options + */ +export default function getInternalOptionsFormat (productOption) { + return productOption.extension_attributes.configurable_item_options + .map(({ label, value }) => ({ label, value })) +} diff --git a/core/modules/catalog/helpers/productOptions/getProductConfiguration.ts b/core/modules/catalog/helpers/productOptions/getProductConfiguration.ts new file mode 100644 index 000000000..55c8e6ea9 --- /dev/null +++ b/core/modules/catalog/helpers/productOptions/getProductConfiguration.ts @@ -0,0 +1,28 @@ +import { optionLabel } from '@vue-storefront/core/modules/catalog/helpers'; +import getAttributeCode from './getAttributeCode' +import getSelectedOption from './getSelectedOption' + +/** + * Returns configuration based on selected variant. Only applies to configurable product + */ +export default function getProductConfiguration ({ product, selectedVariant, attribute }) { + const currentProductOption = {} + const configurableOptions = product.configurable_options || [] + for (const option of configurableOptions) { + const attributeCode = getAttributeCode(option, attribute) + const selectedOption = getSelectedOption(selectedVariant, attributeCode, option) + const label = selectedOption.label + ? selectedOption.label + : optionLabel(attribute, { + attributeKey: selectedOption.attribute_code, + searchBy: 'code', + optionId: selectedOption.value + }) + currentProductOption[attributeCode] = { + attribute_code: attributeCode, + id: String(selectedOption.value), + label: label + } + } + return currentProductOption +} diff --git a/core/modules/catalog/helpers/productOptions/getProductConfigurationOptions.ts b/core/modules/catalog/helpers/productOptions/getProductConfigurationOptions.ts new file mode 100644 index 000000000..93a0adf45 --- /dev/null +++ b/core/modules/catalog/helpers/productOptions/getProductConfigurationOptions.ts @@ -0,0 +1,27 @@ +import getAttributeCode from './getAttributeCode' +import trim from 'lodash-es/trim' +import { optionLabel } from '@vue-storefront/core/modules/catalog/helpers'; + +export default function getProductConfigurationOptions ({ product, attribute }) { + const productOptions = {} + const configurableOptions = product.configurable_options || [] + for (let option of configurableOptions) { + const attributeCode = getAttributeCode(option, attribute) + const productOptionValues = option.values + .map((optionValue) => ({ + label: optionValue.label + ? optionValue.label + : optionLabel(attribute, { + attributeKey: option.attribute_id, + searchBy: 'id', + optionId: optionValue.value_index + }), + id: String(optionValue.value_index), + attribute_code: option.attribute_code + })) + .filter((optionValue) => trim(optionValue.label) !== '') + + productOptions[attributeCode] = productOptionValues + } + return productOptions +} diff --git a/core/modules/catalog/helpers/productOptions/getSelectedOption.ts b/core/modules/catalog/helpers/productOptions/getSelectedOption.ts new file mode 100644 index 000000000..a3491f0b6 --- /dev/null +++ b/core/modules/catalog/helpers/productOptions/getSelectedOption.ts @@ -0,0 +1,19 @@ +/** + * Returns single option for configurable product based on attribute code + */ +export default function getSelectedOption (selectedVariant, attributeCode, option) { + let selectedOption = (selectedVariant.custom_attributes || []).find((a) => a.attribute_code === attributeCode) + selectedOption = selectedOption || { + attribute_code: attributeCode, + value: selectedVariant[attributeCode] + } + if (option.values && option.values.length) { + const selectedOptionMeta = option.values.find(ov => ov.value_index === selectedOption.value) + if (selectedOptionMeta) { + selectedOption.label = selectedOptionMeta.label + ? selectedOptionMeta.label + : selectedOptionMeta.default_label + } + } + return selectedOption +} diff --git a/core/modules/catalog/helpers/productOptions/index.ts b/core/modules/catalog/helpers/productOptions/index.ts new file mode 100644 index 000000000..6370d77c6 --- /dev/null +++ b/core/modules/catalog/helpers/productOptions/index.ts @@ -0,0 +1,9 @@ +import setProductConfigurableOptions from './setProductConfigurableOptions' +import getProductConfiguration from './getProductConfiguration' +import getProductConfigurationOptions from './getProductConfigurationOptions' + +export { + setProductConfigurableOptions, + getProductConfiguration, + getProductConfigurationOptions +} diff --git a/core/modules/catalog/helpers/productOptions/omitInternalOptionsFormat.ts b/core/modules/catalog/helpers/productOptions/omitInternalOptionsFormat.ts new file mode 100644 index 000000000..1df256ba1 --- /dev/null +++ b/core/modules/catalog/helpers/productOptions/omitInternalOptionsFormat.ts @@ -0,0 +1,9 @@ +import omit from 'lodash/omit' + +/** + * Omit props that is not needed for product_option + */ +export default function omitInternalOptionsFormat (productOption) { + productOption.extension_attributes.configurable_item_options = productOption.extension_attributes.configurable_item_options + .map((option) => omit(option, ['label', 'value'])) +} diff --git a/core/modules/catalog/helpers/productOptions/setProductConfigurableOptions.ts b/core/modules/catalog/helpers/productOptions/setProductConfigurableOptions.ts new file mode 100644 index 000000000..b71c226ab --- /dev/null +++ b/core/modules/catalog/helpers/productOptions/setProductConfigurableOptions.ts @@ -0,0 +1,23 @@ +import getAllProductConfigurations from './getAllProductConfigurations' +import getInternalOptionsFormat from './getInternalOptionsFormat' +import omitInternalOptionsFormat from './omitInternalOptionsFormat' + +/** + * set 'product_option' and 'options' based on selected configuration + */ +export default function setProductConfigurableOptions ({ product, configuration, setConfigurableProductOptions }) { + // return if there is no 'setConfigurableProductOptions' option + if (!setConfigurableProductOptions) return + + const configurableOptions = product.configurable_options + + if (!configurableOptions) return + + const productOptions = getAllProductConfigurations({ configurableOptions, configuration }) + + product.options = getInternalOptionsFormat(productOptions) + + omitInternalOptionsFormat(productOptions) + + product.product_option = productOptions +} diff --git a/core/modules/catalog/helpers/registerProductsMapping.ts b/core/modules/catalog/helpers/registerProductsMapping.ts new file mode 100644 index 000000000..13ebf84d2 --- /dev/null +++ b/core/modules/catalog/helpers/registerProductsMapping.ts @@ -0,0 +1,16 @@ +import { transformProductUrl } from '@vue-storefront/core/modules/url/helpers/transformUrl'; +import { localizedDispatcherRoute } from '@vue-storefront/core/lib/multistore'; +import { ActionContext } from 'vuex' +import RootState from '@vue-storefront/core/types/RootState' + +export default async function registerProductsMapping ({ dispatch }: ActionContext, products = []): Promise { + await Promise.all(products.map(product => { + if (product.url_path) { + const { url_path, sku, slug, type_id, parentSku } = product + return dispatch('url/registerMapping', { + url: localizedDispatcherRoute(url_path), + routeData: transformProductUrl({ sku, parentSku, slug, type_id }) + }, { root: true }) + } + })) +} diff --git a/core/modules/catalog/helpers/slugifyCategories.ts b/core/modules/catalog/helpers/slugifyCategories.ts index 0436de07c..930cf1874 100644 --- a/core/modules/catalog/helpers/slugifyCategories.ts +++ b/core/modules/catalog/helpers/slugifyCategories.ts @@ -12,11 +12,11 @@ const createSlug = (category: ChildrenData): string => { const slugifyCategories = (category: Category | ChildrenData): Category | ChildrenData => { if (category.children_data) { - for (let subcat of category.children_data) { - if (subcat.name && !subcat.slug) { - slugifyCategories({ ...subcat, slug: createSlug(subcat) } as any as ChildrenData) + category.children_data.forEach((subCat: ChildrenData): void => { + if (subCat.name && !subCat.slug) { + slugifyCategories({ ...subCat, slug: createSlug(subCat) } as any as ChildrenData) } - } + }) } return category } diff --git a/core/modules/catalog/helpers/stock/filterChildrenByStockitem.ts b/core/modules/catalog/helpers/stock/filterChildrenByStockitem.ts new file mode 100644 index 000000000..c41fca73a --- /dev/null +++ b/core/modules/catalog/helpers/stock/filterChildrenByStockitem.ts @@ -0,0 +1,15 @@ +/** + * Add 'stock' object to 'configurable_children' and filter configurable child that is not available + */ +export default function filterChildrenByStockitem (product, stockItems = []) { + for (const stockItem of stockItems) { + const confChild = product.configurable_children.find((child) => child.id === stockItem.product_id) + if (!stockItem.is_in_stock || (confChild && confChild.status >= 2/* conf child is disabled */)) { + product.configurable_children = product.configurable_children.filter((child) => child.id !== stockItem.product_id) + } else { + if (confChild) { + confChild.stock = stockItem + } + } + } +} diff --git a/core/modules/catalog/helpers/stock/filterOutUnavailableVariants.ts b/core/modules/catalog/helpers/stock/filterOutUnavailableVariants.ts new file mode 100644 index 000000000..3d289cd7d --- /dev/null +++ b/core/modules/catalog/helpers/stock/filterOutUnavailableVariants.ts @@ -0,0 +1,18 @@ +import filterChildrenByStockitem from './filterChildrenByStockitem' +import { hasConfigurableChildren } from './..'; + +/** + * Add 'stock' object to product. If product is out of stock then add error to product. + * Also check all children for configurable products and remove if any children is out of stock. + */ +export default function filterOutUnavailableVariants (product, stockItems = []) { + const productStockItem = stockItems.find(p => p.product_id === product.id) + product.stock = productStockItem + if (productStockItem && !productStockItem.is_in_stock) { + product.errors.variants = 'No available product variants' + } + + if (product.type_id === 'configurable' && hasConfigurableChildren(product)) { + filterChildrenByStockitem(stockItems, product) + } +} diff --git a/core/modules/catalog/helpers/stock/getStockItems.ts b/core/modules/catalog/helpers/stock/getStockItems.ts new file mode 100644 index 000000000..76e5b98b9 --- /dev/null +++ b/core/modules/catalog/helpers/stock/getStockItems.ts @@ -0,0 +1,24 @@ +import { StockService } from '@vue-storefront/core/data-resolver'; +import config from 'config' + +/** + * Get products skus and products children skus. Based on that search for stock objects and return them. + */ +export default async function getStockItems (products) { + const skuArray = products.map(({ sku, configurable_children = [] }) => { + const childSkus = configurable_children.map((c) => c.sku) + return [sku, ...childSkus] + }).reduce((acc, curr) => acc.concat(curr), []) + if (!config.stock.synchronize) return + try { + const task = await StockService.list(skuArray) + + if (task.resultCode === 200) { + return task.result + } + return [] + } catch (err) { + console.error(err) + return [] + } +} diff --git a/core/modules/catalog/helpers/stock/index.ts b/core/modules/catalog/helpers/stock/index.ts index c25836333..fa4c68299 100644 --- a/core/modules/catalog/helpers/stock/index.ts +++ b/core/modules/catalog/helpers/stock/index.ts @@ -1,7 +1,11 @@ import getStatus from './getStatus' import getProductInfos from './getProductInfos' +import getStockItems from './getStockItems' +import filterOutUnavailableVariants from './filterOutUnavailableVariants' export { getStatus, - getProductInfos + getProductInfos, + getStockItems, + filterOutUnavailableVariants } diff --git a/core/modules/catalog/helpers/transformMetadataToAttributes.ts b/core/modules/catalog/helpers/transformMetadataToAttributes.ts new file mode 100644 index 000000000..e857f1008 --- /dev/null +++ b/core/modules/catalog/helpers/transformMetadataToAttributes.ts @@ -0,0 +1,34 @@ +import uniqBy from 'lodash-es/uniqBy' + +const transformMetadataToAttributes = (attributeMetadata) => attributeMetadata + .reduce((prev, curr) => ([ ...prev, ...curr ]), []) + .reduce((prev, curr) => { + const attribute = prev.find(a => a.attribute_id === curr.attribute_id && a.options) + + if (attribute) { + return prev.map(attr => { + if (attr.attribute_id === curr.attribute_id) { + return { + ...attr, + options: uniqBy([...(attr.options || []), ...(curr.options || [])], (obj) => `${obj.label}_${obj.value}`) + } + } + + return attr + }) + } + + return [...prev, curr] + }, []) + .reduce((prev, curr) => ({ + attrHashByCode: { + ...(prev.attrHashByCode || {}), + [curr.attribute_code]: curr + }, + attrHashById: { + ...(prev.attrHashById || {}), + [curr.attribute_id]: curr + } + }), { attrHashByCode: {}, attrHashById: {} }) + +export default transformMetadataToAttributes diff --git a/core/modules/catalog/helpers/variant/findConfigurableVariant.ts b/core/modules/catalog/helpers/variant/findConfigurableVariant.ts new file mode 100644 index 000000000..3587d57f5 --- /dev/null +++ b/core/modules/catalog/helpers/variant/findConfigurableVariant.ts @@ -0,0 +1,43 @@ +import getConfigurationMatchLevel from './getConfigurationMatchLevel' +import getVariantWithLowestPrice from './getVariantWithLowestPrice' +import config from 'config' + +/** + * This function responsiblity is to find best matching variant for configurable product based on configuration object or stock availability. + */ +export default function findConfigurableVariant ({ product, configuration = null, selectDefaultChildren = false, availabilityCheck = true }) { + const selectedVariant = product.configurable_children.reduce((prevVariant, nextVariant) => { + if (availabilityCheck) { + if (nextVariant.stock && !config.products.listOutOfStockProducts) { + if (!nextVariant.stock.is_in_stock) { + return prevVariant + } + } + } + if (nextVariant.status >= 2/** disabled product */) { + return prevVariant + } + if (selectDefaultChildren) { + return prevVariant || nextVariant // return first + } + if ( + (configuration && configuration.sku) && + (nextVariant.sku === configuration.sku) + ) { // by sku or first one + return nextVariant + } else { + // get match level for each variant + const prevVariantMatch = getConfigurationMatchLevel(configuration, prevVariant) + const nextVariantMatch = getConfigurationMatchLevel(configuration, nextVariant) + + // if we have draw between prev variant and current variant then return one that has lowest price + if (prevVariantMatch === nextVariantMatch) { + return getVariantWithLowestPrice(prevVariant, nextVariant) + } + + // return variant with best matching level + return nextVariantMatch > prevVariantMatch ? nextVariant : prevVariant + } + }, undefined) + return selectedVariant +} diff --git a/core/modules/catalog/helpers/variant/getConfigurationMatchLevel.ts b/core/modules/catalog/helpers/variant/getConfigurationMatchLevel.ts new file mode 100644 index 000000000..5ea5999b1 --- /dev/null +++ b/core/modules/catalog/helpers/variant/getConfigurationMatchLevel.ts @@ -0,0 +1,23 @@ +import toString from 'lodash/toString' +import omit from 'lodash/omit' + +/** + * Counts how much coniguration match for specific variant + */ +export default function getConfigurationMatchLevel (configuration, variant): number { + if (!variant || !configuration) return 0 + const configProperties = Object.keys(omit(configuration, ['price'])) + return configProperties + .map(configProperty => { + const variantPropertyId = variant[configProperty] + if (configuration[configProperty] === null) { + return false + } + + return [].concat(configuration[configProperty]) + .map(f => typeof f === 'object' ? toString(f.id) : f) + .includes(toString(variantPropertyId)) + }) + .filter(Boolean) + .length +} diff --git a/core/modules/catalog/helpers/variant/getSelectedVariant.ts b/core/modules/catalog/helpers/variant/getSelectedVariant.ts new file mode 100644 index 000000000..359705bfa --- /dev/null +++ b/core/modules/catalog/helpers/variant/getSelectedVariant.ts @@ -0,0 +1,15 @@ +import findConfigurableVariant from './findConfigurableVariant' + +/** + * Returns product based on configuration or if there is no match then return first variant as default. + */ +export default function getSelectedVariant (product, configuration, { fallbackToDefaultWhenNoAvailable }) { + let selectedVariant = findConfigurableVariant({ product, configuration, availabilityCheck: true }) + if (!selectedVariant) { + if (fallbackToDefaultWhenNoAvailable) { + selectedVariant = findConfigurableVariant({ product, selectDefaultChildren: true, availabilityCheck: true }) // return first available child + } + } + + return selectedVariant +} diff --git a/core/modules/catalog/helpers/variant/getVariantWithLowestPrice.ts b/core/modules/catalog/helpers/variant/getVariantWithLowestPrice.ts new file mode 100644 index 000000000..1dbfa6879 --- /dev/null +++ b/core/modules/catalog/helpers/variant/getVariantWithLowestPrice.ts @@ -0,0 +1,12 @@ +/** + * Makes product variants comparission and returns variant with lowest price + */ +export default function getVariantWithLowestPrice (prevVariant, nextVariant) { + if (!prevVariant || !prevVariant.original_price_incl_tax) { + return nextVariant + } + + const prevPrice = prevVariant.price_incl_tax || prevVariant.original_price_incl_tax + const nextPrice = nextVariant.price_incl_tax || nextVariant.original_price_incl_tax + return nextPrice < prevPrice ? nextVariant : prevVariant +} diff --git a/core/modules/catalog/helpers/variant/index.ts b/core/modules/catalog/helpers/variant/index.ts new file mode 100644 index 000000000..6a9bdc17a --- /dev/null +++ b/core/modules/catalog/helpers/variant/index.ts @@ -0,0 +1,11 @@ +import omitSelectedVariantFields from './omitSelectedVariantFields' +import getSelectedVariant from './getSelectedVariant' +import isOptionAvailable from './isOptionAvailable' +import findConfigurableVariant from './findConfigurableVariant' + +export { + omitSelectedVariantFields, + getSelectedVariant, + isOptionAvailable, + findConfigurableVariant +} diff --git a/core/modules/catalog/helpers/variant/isOptionAvailable.ts b/core/modules/catalog/helpers/variant/isOptionAvailable.ts new file mode 100644 index 000000000..1c5ee5542 --- /dev/null +++ b/core/modules/catalog/helpers/variant/isOptionAvailable.ts @@ -0,0 +1,9 @@ +import findConfigurableVariant from './findConfigurableVariant' + +/** + * Checks if variant with specific configuration exist + */ +export default function isOptionAvailable (context, { product, configuration }): boolean { + const variant = findConfigurableVariant({ product: product, configuration: configuration, availabilityCheck: true }) + return typeof variant !== 'undefined' && variant !== null +} diff --git a/core/modules/catalog/helpers/variant/omitSelectedVariantFields.ts b/core/modules/catalog/helpers/variant/omitSelectedVariantFields.ts new file mode 100644 index 000000000..5b9905271 --- /dev/null +++ b/core/modules/catalog/helpers/variant/omitSelectedVariantFields.ts @@ -0,0 +1,11 @@ +import omit from 'lodash/omit' + +/** + * Omit some variant fields to prevent overriding same base product fields + */ +export default function omitSelectedVariantFields (selectedVariant): void { + const hasImage = selectedVariant && selectedVariant.image && selectedVariant.image !== 'no_selection' + const fieldsToOmit = ['name', 'visibility'] + if (!hasImage) fieldsToOmit.push('image') + return omit(selectedVariant, fieldsToOmit) +} diff --git a/core/modules/catalog/index.ts b/core/modules/catalog/index.ts index 33ff1e321..abbb1a45f 100644 --- a/core/modules/catalog/index.ts +++ b/core/modules/catalog/index.ts @@ -23,9 +23,11 @@ export const CatalogModule: StorefrontModule = async function ({ store, router, store.registerModule('tax', taxModule) store.registerModule('category', categoryModule) - await store.dispatch('attribute/list', { // loading attributes for application use - filterValues: uniq([...config.products.defaultFilters, ...config.entities.productListWithChildren.includeFields]) - }) + if (!config.entities.attribute.loadByAttributeMetadata) { + await store.dispatch('attribute/list', { // loading attributes for application use + filterValues: uniq([...config.products.defaultFilters, ...config.entities.productListWithChildren.includeFields]) + }) + } if (!isServer) { // Things moved from Product.js diff --git a/core/modules/catalog/queries/common.js b/core/modules/catalog/queries/common.js index a77db5fac..adcc18346 100644 --- a/core/modules/catalog/queries/common.js +++ b/core/modules/catalog/queries/common.js @@ -1,4 +1,4 @@ -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' +import { SearchQuery } from 'storefront-query-builder' import config from 'config' export function prepareQuery ({ queryText = '', filters = [], queryConfig = '' }) { diff --git a/core/modules/catalog/queries/related.js b/core/modules/catalog/queries/related.js index 8bda79a1a..12a0d46da 100644 --- a/core/modules/catalog/queries/related.js +++ b/core/modules/catalog/queries/related.js @@ -1,4 +1,4 @@ -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' +import { SearchQuery } from 'storefront-query-builder' import config from 'config' export function prepareRelatedQuery (key, sku) { diff --git a/core/modules/catalog/queries/searchPanel.js b/core/modules/catalog/queries/searchPanel.js index f8fad4b75..3543093f0 100644 --- a/core/modules/catalog/queries/searchPanel.js +++ b/core/modules/catalog/queries/searchPanel.js @@ -1,4 +1,4 @@ -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' +import { SearchQuery } from 'storefront-query-builder' import config from 'config' export function prepareQuickSearchQuery (queryText) { diff --git a/core/modules/catalog/store/attribute/actions.ts b/core/modules/catalog/store/attribute/actions.ts index 38a9aed07..d89fa3246 100644 --- a/core/modules/catalog/store/attribute/actions.ts +++ b/core/modules/catalog/store/attribute/actions.ts @@ -1,16 +1,17 @@ import * as types from './mutation-types' import { quickSearchByQuery } from '@vue-storefront/core/lib/search' import { StorageManager } from '@vue-storefront/core/lib/storage-manager' -import AttributeState from '../../types/AttributeState' +import AttributeState from '@vue-storefront/core/modules/catalog/types/AttributeState' import RootState from '@vue-storefront/core/types/RootState' import { ActionTree } from 'vuex' import config from 'config' import { Logger } from '@vue-storefront/core/lib/logger' import { entityKeyName } from '@vue-storefront/core/lib/store/entities' -import { prefetchCachedAttributes } from '../../helpers/prefetchCachedAttributes' -import createAttributesListQuery from './../../helpers/createAttributesListQuery' -import reduceAttributesLists from './../../helpers/reduceAttributesLists' -import filterAttributes from '../../helpers/filterAttributes' +import { prefetchCachedAttributes } from '@vue-storefront/core/modules/catalog/helpers/prefetchCachedAttributes' +import createAttributesListQuery from '@vue-storefront/core/modules/catalog/helpers/createAttributesListQuery' +import reduceAttributesLists from '@vue-storefront/core/modules/catalog/helpers/reduceAttributesLists' +import transformMetadataToAttributes from '@vue-storefront/core/modules/catalog/helpers/transformMetadataToAttributes' +import filterAttributes from '@vue-storefront/core/modules/catalog/helpers/filterAttributes' const actions: ActionTree = { async updateAttributes ({ commit, getters }, { attributes }) { @@ -82,6 +83,26 @@ const actions: ActionTree = { await dispatch('updateAttributes', { attributes }) return resp + }, + async loadProductAttributes (context, { products, merge = false }) { + const attributeMetadata = products + .filter(product => product.attributes_metadata) + .map(product => product.attributes_metadata) + + const attributes = transformMetadataToAttributes(attributeMetadata) + + if (merge) { + attributes.attrHashByCode = { ...attributes.attrHashByCode, ...context.state.list_by_code } + attributes.attrHashById = { ...attributes.attrHashById, ...context.state.list_by_id } + } + + context.commit(types.ATTRIBUTE_UPD_ATTRIBUTES, attributes) + }, + async loadCategoryAttributes (context, { attributeMetadata }) { + if (!attributeMetadata) return + const attributes = transformMetadataToAttributes([attributeMetadata]) + + context.commit(types.ATTRIBUTE_UPD_ATTRIBUTES, attributes) } } diff --git a/core/modules/catalog/store/category/actions.ts b/core/modules/catalog/store/category/actions.ts index ba590a658..e309307a4 100644 --- a/core/modules/catalog/store/category/actions.ts +++ b/core/modules/catalog/store/category/actions.ts @@ -11,7 +11,6 @@ import toString from 'lodash-es/toString' import { optionLabel } from '../../helpers/optionLabel' import RootState from '@vue-storefront/core/types/RootState' import CategoryState from '../../types/CategoryState' -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' import { currentStoreView, localizedDispatcherRoute, localizedDispatcherRouteName } from '@vue-storefront/core/lib/multistore' import { Logger } from '@vue-storefront/core/lib/logger' import { isServer } from '@vue-storefront/core/helpers' @@ -20,6 +19,7 @@ import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' import { StorageManager } from '@vue-storefront/core/lib/storage-manager' import createCategoryListQuery from '@vue-storefront/core/modules/catalog/helpers/createCategoryListQuery' import { formatCategoryLink } from 'core/modules/url/helpers' +import { transformCategoryUrl } from '@vue-storefront/core/modules/url/helpers/transformUrl'; const actions: ActionTree = { /** @@ -68,12 +68,7 @@ const actions: ActionTree = { if (category.url_path) { await dispatch('url/registerMapping', { url: localizedDispatcherRoute(category.url_path, storeCode), - routeData: { - params: { - 'slug': category.slug - }, - 'name': localizedDispatcherRouteName('category', storeCode, appendStoreCode) - } + routeData: transformCategoryUrl(category) }, { root: true }) } } diff --git a/core/modules/catalog/store/product/actions.ts b/core/modules/catalog/store/product/actions.ts index 0f0c7e9fe..82c1471c7 100644 --- a/core/modules/catalog/store/product/actions.ts +++ b/core/modules/catalog/store/product/actions.ts @@ -1,282 +1,44 @@ -import Vue from 'vue' import { ActionTree } from 'vuex' import * as types from './mutation-types' -import { formatBreadCrumbRoutes, isServer } from '@vue-storefront/core/helpers' -import { currentStoreView, localizedDispatcherRoute, localizedDispatcherRouteName } from '@vue-storefront/core/lib/multistore' -import { configureProductAsync, - doPlatformPricesSync, - filterOutUnavailableVariants, - populateProductConfigurationAsync, - setCustomProductOptionsAsync, - setBundleProductOptionsAsync, - getMediaGallery, - configurableChildrenImages, - attributeImages } from '../../helpers' -import { preConfigureProduct, getOptimizedFields, configureChildren, storeProductToCache, canCache, isGroupedOrBundle } from '@vue-storefront/core/modules/catalog/helpers/search' -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' -import { entityKeyName } from '@vue-storefront/core/lib/store/entities' -import { optionLabel } from '../../helpers/optionLabel' -import { isOnline } from '@vue-storefront/core/lib/search' -import omit from 'lodash-es/omit' -import trim from 'lodash-es/trim' +import { isServer } from '@vue-storefront/core/helpers' +import { SearchQuery } from 'storefront-query-builder' import cloneDeep from 'lodash-es/cloneDeep' -import uniqBy from 'lodash-es/uniqBy' import rootStore from '@vue-storefront/core/store' import RootState from '@vue-storefront/core/types/RootState' import ProductState from '../../types/ProductState' import { Logger } from '@vue-storefront/core/lib/logger'; -import { TaskQueue } from '@vue-storefront/core/lib/sync' -import toString from 'lodash-es/toString' import config from 'config' import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' -import { StorageManager } from '@vue-storefront/core/lib/storage-manager' -import { quickSearchByQuery } from '@vue-storefront/core/lib/search' -import { formatProductLink } from 'core/modules/url/helpers' +import { ProductService } from '@vue-storefront/core/data-resolver/ProductService' +import { + registerProductsMapping, + doPlatformPricesSync, + setCustomProductOptionsAsync, + setBundleProductOptionsAsync, + getProductGallery, + setRequestCacheTags +} from '@vue-storefront/core/modules/catalog/helpers' +import { getProductConfigurationOptions } from '@vue-storefront/core/modules/catalog/helpers/productOptions' import { checkParentRedirection } from '@vue-storefront/core/modules/catalog/events' -const PRODUCT_REENTER_TIMEOUT = 20000 - const actions: ActionTree = { - /** - * Reset current configuration and selected variatnts - */ - reset (context) { - const originalProduct = Object.assign({}, context.getters.getOriginalProduct) - context.commit(types.PRODUCT_RESET_CURRENT, originalProduct) - }, - /** - * Setup product breadcrumbs path - */ - async setupBreadcrumbs (context, { product }) { - console.warn('deprecated, will be removed in 1.13') - let breadcrumbsName = null - let setBreadcrumbRoutesFromPath = (path) => { - if (path.findIndex(itm => { - return itm.slug === context.rootGetters['category/getCurrentCategory'].slug - }) < 0) { - path.push({ - url_path: context.rootGetters['category/getCurrentCategory'].url_path, - slug: context.rootGetters['category/getCurrentCategory'].slug, - name: context.rootGetters['category/getCurrentCategory'].name - }) // current category at the end - } - // deprecated, TODO: base on breadcrumbs module - breadcrumbsName = product.name - const breadcrumbs = { - routes: formatBreadCrumbRoutes(path), - current: breadcrumbsName, - name: breadcrumbsName - } - context.commit(types.CATALOG_SET_BREADCRUMBS, breadcrumbs) - } - - if (product.category && product.category.length > 0) { - const categoryIds = product.category.reverse().map(cat => cat.category_id) - await context.dispatch('category/list', { key: 'id', value: categoryIds }, { root: true }).then(async (categories) => { - const catList = [] - - for (let catId of categoryIds) { - let category = categories.items.find((itm) => { return toString(itm['id']) === toString(catId) }) - if (category) { - catList.push(category) - } - } - - const rootCat = catList.shift() - let catForBreadcrumbs = rootCat - - for (let cat of catList) { - const catPath = cat.path - if (catPath && catPath.includes(rootCat.path) && (catPath.split('/').length > catForBreadcrumbs.path.split('/').length)) { - catForBreadcrumbs = cat - } - } - if (typeof catForBreadcrumbs !== 'undefined') { - await context.dispatch('category/single', { key: 'id', value: catForBreadcrumbs.id }, { root: true }).then(() => { // this sets up category path and current category - setBreadcrumbRoutesFromPath(context.rootGetters['category/getCurrentCategoryPath']) - }).catch(err => { - setBreadcrumbRoutesFromPath(context.rootGetters['category/getCurrentCategoryPath']) - Logger.error(err)() - }) - } else { - setBreadcrumbRoutesFromPath(context.rootGetters['category/getCurrentCategoryPath']) - } - }) - } - }, doPlatformPricesSync (context, { products }) { return doPlatformPricesSync(products) }, - /** - * Download Magento2 / other platform prices to put them over ElasticSearch prices - */ - syncPlatformPricesOver ({ rootGetters }, { skus }) { - const storeView = currentStoreView() - let url = `${config.products.endpoint}/render-list?skus=${encodeURIComponent(skus.join(','))}¤cyCode=${encodeURIComponent(storeView.i18n.currencyCode)}&storeId=${encodeURIComponent(storeView.storeId)}` - if (rootGetters['tax/getIsUserGroupedTaxActive']) { - url = `${url}&userGroupId=${rootGetters['tax/getUserTaxGroupId']}` - } - - return TaskQueue.execute({ url, // sync the cart - payload: { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - mode: 'cors' - }, - callback_event: 'prices-after-sync' - }).then((task: any) => { - return task.result - }) - }, - /** - * Setup associated products - */ - setupAssociated (context, { product, skipCache = true }) { - let subloaders = [] - if (product.type_id === 'grouped') { - product.price = 0 - product.price_incl_tax = 0 - Logger.debug(product.name + ' SETUP ASSOCIATED', product.type_id)() - if (product.product_links && product.product_links.length > 0) { - for (let pl of product.product_links) { - if (pl.link_type === 'associated' && pl.linked_product_type === 'simple') { // prefetch links - Logger.debug('Prefetching grouped product link for ' + pl.sku + ' = ' + pl.linked_product_sku)() - subloaders.push(context.dispatch('single', { - options: { sku: pl.linked_product_sku }, - setCurrentProduct: false, - selectDefaultVariant: false, - skipCache: skipCache - }).catch(err => { Logger.error(err) }).then((asocProd) => { - if (asocProd) { - pl.product = asocProd - pl.product.qty = 1 - product.price += pl.product.price - product.price_incl_tax += pl.product.price_incl_tax - product.tax += pl.product.tax - } else { - Logger.error('Product link not found', pl.linked_product_sku)() - } - })) - } - } - } else { - Logger.error('Product with type grouped has no product_links set!', product)() - } - } - if (product.type_id === 'bundle') { - product.price = 0 - product.price_incl_tax = 0 - Logger.debug(product.name + ' SETUP ASSOCIATED', product.type_id)() - if (product.bundle_options && product.bundle_options.length > 0) { - for (let bo of product.bundle_options) { - let defaultOption = bo.product_links.find((p) => { return p.is_default }) - if (!defaultOption) defaultOption = bo.product_links[0] - for (let pl of bo.product_links) { - Logger.debug('Prefetching bundle product link for ' + bo.sku + ' = ' + pl.sku)() - subloaders.push(context.dispatch('single', { - options: { sku: pl.sku }, - setCurrentProduct: false, - selectDefaultVariant: false, - skipCache: skipCache - }).catch(err => { Logger.error(err) }).then((asocProd) => { - if (asocProd) { - pl.product = asocProd - pl.product.qty = pl.qty - - if (pl.id === defaultOption.id) { - product.price += pl.product.price * pl.product.qty - product.price_incl_tax += pl.product.price_incl_tax * pl.product.qty - product.tax += pl.product.tax * pl.product.qty - } - } else { - Logger.error('Product link not found', pl.sku)() - } - })) - } - } - } - } - return Promise.all(subloaders) - }, /** * This is fix for https://github.com/DivanteLtd/vue-storefront/issues/508 * TODO: probably it would be better to have "parent_id" for simple products or to just ensure configurable variants are not visible in categories/search */ - checkConfigurableParent (context, { product }) { + checkConfigurableParent ({ commit, dispatch, getters }, { product }) { if (product.type_id === 'simple') { Logger.log('Checking configurable parent')() - - let searchQuery = new SearchQuery() - searchQuery = searchQuery.applyFilter({ key: 'configurable_children.sku', value: { 'eq': context.getters.getCurrentProduct.sku } }) - - return context.dispatch('list', { query: searchQuery, start: 0, size: 1, updateState: false }).then((resp) => { - if (resp.items.length >= 1) { - const parentProduct = resp.items[0] - context.commit(types.PRODUCT_SET_PARENT, parentProduct) - } - }).catch((err) => { - Logger.error(err)() - }) - } - }, - /** - * Load required configurable attributes - * @param context - * @param product - */ - loadConfigurableAttributes (context, { product }) { - let attributeKey = 'attribute_id' - const configurableAttrKeys = product.configurable_options.map(opt => { - if (opt.attribute_id) { - attributeKey = 'attribute_id' - return opt.attribute_id - } else { - attributeKey = 'attribute_code' - return opt.attribute_code + const parent = dispatch('findConfigurableParent', { product: { sku: getters.getCurrentProduct.sku } }) + if (parent) { + commit(types.PRODUCT_SET_PARENT, parent) } - }) - return context.dispatch('attribute/list', { - filterValues: configurableAttrKeys, - filterField: attributeKey - }, { root: true }) - }, - /** - * Setup product current variants - */ - setupVariants (context, { product }) { - let subloaders = [] - if (product.type_id === 'configurable' && product.hasOwnProperty('configurable_options')) { - subloaders.push(context.dispatch('product/loadConfigurableAttributes', { product }, { root: true }).then((attributes) => { - let productOptions = {} - for (let option of product.configurable_options) { - for (let ov of option.values) { - let lb = ov.label ? ov.label : optionLabel(context.rootState.attribute, { attributeKey: option.attribute_id, searchBy: 'id', optionId: ov.value_index }) - if (trim(lb) !== '') { - let optionKey = option.attribute_code ? option.attribute_code : option.label.toLowerCase() - if (!productOptions[optionKey]) { - productOptions[optionKey] = [] - } - productOptions[optionKey].push({ - label: lb, - id: ov.value_index, - attribute_code: option.attribute_code - }) - } - } - } - context.commit(types.PRODUCT_SET_CURRENT_OPTIONS, productOptions) - let selectedVariant = context.getters.getCurrentProduct - populateProductConfigurationAsync(context, { selectedVariant: selectedVariant, product: product }) - }).catch(err => { - Logger.error(err)() - })) + return parent } - return Promise.all(subloaders) - }, - filterUnavailableVariants (context, { product }) { - return filterOutUnavailableVariants(context, product) }, - /** * Search ElasticSearch catalog of products using simple text query * Use bodybuilder to build the query, aggregations etc: http://bodybuilder.js.org/ @@ -285,78 +47,96 @@ const actions: ActionTree = { * @param {Int} size page size * @return {Promise} */ - async list ({ dispatch, commit }, { query, start = 0, size = 50, entityType = 'product', sort = '', cacheByKey = 'sku', prefetchGroupProducts = !isServer, updateState = false, meta = {}, excludeFields = null, includeFields = null, configuration = null, append = false, populateRequestCacheTags = true }) { - const searchResult = await dispatch('findProducts', { query, start, size, entityType, sort, cacheByKey, excludeFields, includeFields, configuration, populateRequestCacheTags }) - await dispatch('preConfigureAssociated', { searchResult, prefetchGroupProducts }) + async list (context, { + query, + start = 0, + size = 50, + sort = '', + prefetchGroupProducts = !isServer, + excludeFields = null, + includeFields = null, + configuration = null, + populateRequestCacheTags = true, + updateState = false, + append = false + } = {}) { + Logger.warn('`product/list` deprecated, will be not used from 1.12, use "findProducts" instead')() + const { items } = await context.dispatch('findProducts', { + query, + start, + size, + sort, + excludeFields, + includeFields, + configuration, + options: { + populateRequestCacheTags, + prefetchGroupProducts + } + }) if (updateState) { - if (append) commit(types.PRODUCT_ADD_PAGED_PRODUCTS, searchResult) - else commit(types.PRODUCT_SET_PAGED_PRODUCTS, searchResult) + Logger.warn('updateState and append are deprecated, will be not used from 1.12')() + if (append) context.commit(types.PRODUCT_ADD_PAGED_PRODUCTS, { items }) + else context.commit(types.PRODUCT_SET_PAGED_PRODUCTS, { items }) } - EventBus.$emit('product-after-list', { query, start, size, sort, entityType, meta, result: searchResult }) - - return searchResult - }, - preConfigureAssociated (context, { searchResult, prefetchGroupProducts }) { - const { storeCode, appendStoreCode } = currentStoreView() - for (let product of searchResult.items) { - if (product.url_path) { - const { parentSku, slug } = product - - context.dispatch('url/registerMapping', { - url: localizedDispatcherRoute(product.url_path, storeCode), - routeData: { - params: { parentSku, slug }, - 'name': localizedDispatcherRouteName(product.type_id + '-product', storeCode, appendStoreCode) - } - }, { root: true }) + EventBus.$emit('product-after-list', { query, start, size, sort, entityType: 'product', result: { items } }) + + return { items } + }, + async findProducts (context, { + query, + start = 0, + size = 50, + sort = '', + excludeFields = null, + includeFields = null, + configuration = null, + populateRequestCacheTags = false, + options: { + populateRequestCacheTags: populateRequestCacheTagsNew = false, + prefetchGroupProducts = !isServer, + setProductErrors = false, + fallbackToDefaultWhenNoAvailable = true, + assignProductConfiguration = false, + separateSelectedVariant = false, + setConfigurableProductOptions = config.cart.setConfigurableProductOptions, + filterUnavailableVariants = config.products.filterUnavailableVariants + } = {} + } = {}) { + const { items, ...restResponseData } = await ProductService.getProducts({ + query, + start, + size, + sort, + excludeFields, + includeFields, + configuration, + options: { + prefetchGroupProducts, + fallbackToDefaultWhenNoAvailable, + setProductErrors, + setConfigurableProductOptions, + filterUnavailableVariants, + assignProductConfiguration, + separateSelectedVariant } + }) - if (isGroupedOrBundle(product) && prefetchGroupProducts && !isServer) { - context.dispatch('setupAssociated', { product }) - } - } - }, - preConfigureProduct (context, { product, populateRequestCacheTags, configuration }) { - console.warn('deprecated, will be removed in 1.13') - let prod = preConfigureProduct({ product, populateRequestCacheTags }) + registerProductsMapping(context, items) - if (configuration) { - const selectedVariant = configureProductAsync(context, { product: prod, selectDefaultVariant: false, configuration }) - prod = Object.assign({}, prod, omit(selectedVariant, ['visibility'])) + if (populateRequestCacheTags) { + Logger.warn('deprecated from 1.13, use "options.populateRequestCacheTags" instead')() } - return prod - }, - async configureLoadedProducts (context, { products, isCacheable, cacheByKey, populateRequestCacheTags, configuration }) { - const configuredProducts = await context.dispatch( - 'category-next/configureProducts', - { - products: products.items, - filters: configuration || {}, - populateRequestCacheTags - }, - { root: true } - ) - - await context.dispatch('tax/calculateTaxes', { products: configuredProducts }, { root: true }) - - for (let product of configuredProducts) { // we store each product separately in cache to have offline access to products/single method - if (isCacheable) { // store cache only for full loads - storeProductToCache(product, cacheByKey) - } + if (populateRequestCacheTags || populateRequestCacheTagsNew) { + setRequestCacheTags({ products: items }) } - return products - }, - async findProducts (context, { query, start = 0, size = 50, entityType = 'product', sort = '', cacheByKey = 'sku', excludeFields = null, includeFields = null, configuration = null, populateRequestCacheTags = true }) { - const isCacheable = canCache({ includeFields, excludeFields }) - const { excluded, included } = getOptimizedFields({ excludeFields, includeFields }) - const resp = await quickSearchByQuery({ query, start, size, entityType, sort, excludeFields: excluded, includeFields: included }) - const products = await context.dispatch('configureLoadedProducts', { products: resp, isCacheable, cacheByKey, populateRequestCacheTags, configuration }) + await context.dispatch('tax/calculateTaxes', { products: items }, { root: true }) - return products + return { ...restResponseData, items } }, async findConfigurableParent (context, { product, configuration }) { const searchQuery = new SearchQuery() @@ -364,189 +144,34 @@ const actions: ActionTree = { const products = await context.dispatch('findProducts', { query, configuration }) return products.items && products.items.length > 0 ? products.items[0] : null }, - /** - * Update associated products for bundle product - * @param context - * @param product - */ - configureBundleAsync (context, product) { - return context.dispatch( - 'setupAssociated', { - product: product, - skipCache: true - }) - .then(() => { context.dispatch('setCurrent', product) }) - .then(() => { EventBus.$emit('product-after-setup-associated') }) - }, - - /** - * Update associated products for group product - * @param context - * @param product - */ - configureGroupedAsync (context, product) { - return context.dispatch( - 'setupAssociated', { - product: product, - skipCache: true - }) - .then(() => { context.dispatch('setCurrent', product) }) - }, - /** * Search products by specific field * @param {Object} options */ - async single (context, { options, setCurrentProduct = true, selectDefaultVariant = true, assignDefaultVariant = false, key = 'sku', skipCache = false }) { + async single (context, { + options = {}, + setCurrentProduct = false, + key = 'sku', + skipCache = false + } = {}) { + if (setCurrentProduct) { + Logger.warn('option `setCurrentProduct` is deprecated, will be not used from 1.13')() + } if (!options[key]) { - throw Error('Please provide the search key ' + key + ' for product/single action!') + throw new Error('Please provide the search key ' + key + ' for product/single action!') } - const cacheKey = entityKeyName(key, options[key]) - - return new Promise((resolve, reject) => { - const benchmarkTime = new Date() - const cache = StorageManager.get('elasticCache') - - const setupProduct = (prod) => { - // set product quantity to 1 - if (!prod.qty) { - prod.qty = 1 - } - // set original product - if (setCurrentProduct) { - context.dispatch('setOriginal', prod) - } - // check is prod has configurable children - const hasConfigurableChildren = prod && prod.configurable_children && prod.configurable_children.length - if (prod.type_id === 'simple' && hasConfigurableChildren) { // workaround for #983 - prod = omit(prod, ['configurable_children', 'configurable_options']) - } - - // set current product - configurable or not - if (prod.type_id === 'configurable' && hasConfigurableChildren) { - // set first available configuration - // todo: probably a good idea is to change this [0] to specific id - const selectedVariant = configureProductAsync(context, { product: prod, configuration: { sku: options.childSku }, selectDefaultVariant: selectDefaultVariant, setProductErorrs: true }) - if (selectedVariant && assignDefaultVariant) { - prod = Object.assign({}, prod, selectedVariant) - } - } else if (!skipCache || (prod.type_id === 'simple' || prod.type_id === 'downloadable')) { - if (setCurrentProduct) context.dispatch('setCurrent', prod) - } - - return prod - } - - const syncProducts = () => { - let searchQuery = new SearchQuery() - searchQuery = searchQuery.applyFilter({ key: key, value: { 'eq': options[key] } }) - - return context.dispatch('list', { // product list syncs the platform price on it's own - query: searchQuery, - prefetchGroupProducts: false, - updateState: false - }).then((res) => { - if (res && res.items && res.items.length) { - let prd = res.items[0] - const _returnProductNoCacheHelper = (subresults) => { - EventBus.$emitFilter('product-after-single', { key: key, options: options, product: prd }) - resolve(setupProduct(prd)) - } - if (setCurrentProduct || selectDefaultVariant) { - const subConfigPromises = [] - if (prd.type_id === 'bundle') { - subConfigPromises.push(context.dispatch('configureBundleAsync', prd)) - } - - if (prd.type_id === 'grouped') { - subConfigPromises.push(context.dispatch('configureGroupedAsync', prd)) - } - subConfigPromises.push(context.dispatch('setupVariants', { product: prd })) - Promise.all(subConfigPromises).then(_returnProductNoCacheHelper) - } else { - _returnProductNoCacheHelper(null) - } - } else { - reject(new Error('Product query returned empty result')) - } - }) - } - - const getProductFromCache = () => { - cache.getItem(cacheKey, (err, res) => { - // report errors - if (!skipCache && err) { - Logger.error(err, 'product')() - } - - if (res !== null) { - Logger.debug('Product:single - result from localForage (for ' + cacheKey + '), ms=' + (new Date().getTime() - benchmarkTime.getTime()), 'product')() - const _returnProductFromCacheHelper = (subresults) => { - const cachedProduct = setupProduct(res) - if (config.products.alwaysSyncPlatformPricesOver) { - doPlatformPricesSync([cachedProduct]).then((products) => { - EventBus.$emitFilter('product-after-single', { key: key, options: options, product: products[0] }) - resolve(products[0]) - }) - if (!config.products.waitForPlatformSync) { - EventBus.$emitFilter('product-after-single', { key: key, options: options, product: cachedProduct }) - resolve(cachedProduct) - } - } else { - EventBus.$emitFilter('product-after-single', { key: key, options: options, product: cachedProduct }) - resolve(cachedProduct) - } - } - if (setCurrentProduct || selectDefaultVariant) { - const subConfigPromises = [] - subConfigPromises.push(context.dispatch('setupVariants', { product: res })) - if (res.type_id === 'bundle') { - subConfigPromises.push(context.dispatch('configureBundleAsync', res)) - } - if (res.type_id === 'grouped') { - subConfigPromises.push(context.dispatch('configureGroupedAsync', res)) - } - Promise.all(subConfigPromises).then(_returnProductFromCacheHelper) - } else { - _returnProductFromCacheHelper(null) - } - } else { - syncProducts() - } - }) - } - - if (!skipCache) { - getProductFromCache() - } else { - if (!isOnline()) { - skipCache = false; - } - - syncProducts() - } + const product = await ProductService.getProductByKey({ + options, + key, + skipCache }) - }, - /** - * Configure product with given configuration and set it as current - * @param {Object} context - * @param {Object} product - * @param {Array} configuration - */ - configure (context, { product = null, configuration, selectDefaultVariant = true, fallbackToDefaultWhenNoAvailable = true }) { - return configureProductAsync(context, { product: product, configuration: configuration, selectDefaultVariant: selectDefaultVariant, fallbackToDefaultWhenNoAvailable: fallbackToDefaultWhenNoAvailable }) - }, - setCurrentOption (context, productOption) { - if (productOption && typeof productOption === 'object') { // TODO: this causes some kind of recurrency error - context.commit(types.PRODUCT_SET_CURRENT, Object.assign({}, context.getters.getCurrentProduct, { product_option: productOption })) - } - }, + await context.dispatch('tax/calculateTaxes', { products: [product] }, { root: true }) - setCurrentErrors (context, errors) { - if (errors && typeof errors === 'object') { - context.commit(types.PRODUCT_SET_CURRENT, Object.assign({}, context.getters.getCurrentProduct, { errors: errors })) - } + if (setCurrentProduct) await context.dispatch('setCurrent', product) + EventBus.$emitFilter('product-after-single', { key, options, product }) + + return product }, /** * Assign the custom options object to the currentl product @@ -569,74 +194,46 @@ const actions: ActionTree = { * @param {Object} context * @param {Object} productVariant */ - setCurrent (context, productVariant) { - if (productVariant && typeof productVariant === 'object') { - // get original product - const originalProduct = context.getters.getOriginalProduct - - // check if passed variant is the same as original - const productUpdated = Object.assign({}, originalProduct, productVariant) - populateProductConfigurationAsync(context, { product: productUpdated, selectedVariant: productVariant }) + setCurrent (context, product) { + if (product && typeof product === 'object') { + const { configuration, ...restProduct } = product + const productUpdated = Object.assign({}, restProduct) if (!config.products.gallery.mergeConfigurableChildren) { - context.commit(types.PRODUCT_SET_GALLERY, attributeImages(productVariant)) + context.dispatch('setProductGallery', { product: productUpdated }) } - context.commit(types.PRODUCT_SET_CURRENT, Object.assign({}, productUpdated)) + const productOptions = getProductConfigurationOptions({ product, attribute: context.rootState.attribute }) + context.commit(types.PRODUCT_SET_CURRENT_OPTIONS, productOptions) + context.commit(types.PRODUCT_SET_CURRENT_CONFIGURATION, configuration || {}) + context.commit(types.PRODUCT_SET_CURRENT, productUpdated) return productUpdated } else Logger.debug('Unable to update current product.', 'product')() }, - /** - * Set given product as original - * @param {Object} context - * @param {Object} originalProduct - */ - setOriginal (context, originalProduct) { - if (originalProduct && typeof originalProduct === 'object') context.commit(types.PRODUCT_SET_ORIGINAL, Object.assign({}, originalProduct)) - else Logger.debug('Unable to setup original product.', 'product')() - }, /** * Set related products */ related (context, { key = 'related-products', items }) { context.commit(types.PRODUCT_SET_RELATED, { key, items }) }, - - // Deprecated methods, remove in 2.0 - async fetch () { - throw new Error('product/fetch has been moved into product/loadProduct') - }, - async fetchAsync () { - throw new Error('product/fetchAsync has been moved into product/loadProduct') - }, - - /** - * Load product attributes - */ - async loadProductAttributes ({ dispatch }, { product }) { - const productFields = Object.keys(product).filter(fieldName => { - return !config.entities.product.standardSystemFields.includes(fieldName) // don't load metadata info for standard fields - }) - const { product: { useDynamicAttributeLoader }, optimize, attribute } = config.entities - return dispatch('attribute/list', { // load attributes to be shown on the product details - the request is now async - filterValues: useDynamicAttributeLoader ? productFields : null, - only_visible: !!useDynamicAttributeLoader, - only_user_defined: true, - includeFields: optimize ? attribute.includeFields : null - }, { root: true }) - }, - /** * Load the product data and sets current product */ - async loadProduct ({ dispatch }, { parentSku, childSku = null, route = null }) { + async loadProduct ({ dispatch, state }, { parentSku, childSku = null, route = null, skipCache = false }) { Logger.info('Fetching product data asynchronously', 'product', { parentSku, childSku })() EventBus.$emit('product-before-load', { store: rootStore, route: route }) - await dispatch('reset') - // pass both id and sku to render a product - const productSingleOptions = { - sku: parentSku, - childSku: childSku - } - const product = await dispatch('single', { options: productSingleOptions }) + + const product = await dispatch('single', { + options: { + sku: parentSku, + childSku: childSku + }, + key: 'sku', + skipCache + }) + + setRequestCacheTags({ products: [product] }) + + await dispatch('setCurrent', product) + if (product.status >= 2) { throw new Error(`Product query returned empty result product status = ${product.status}`) } @@ -649,12 +246,15 @@ const actions: ActionTree = { } } - await dispatch('loadProductAttributes', { product }) + if (config.entities.attribute.loadByAttributeMetadata) { + await dispatch('attribute/loadProductAttributes', { products: [product] }, { root: true }) + } else { + await dispatch('loadProductAttributes', { product }) + } + const syncPromises = [] - const variantsFilter = dispatch('filterUnavailableVariants', { product }) const gallerySetup = dispatch('setProductGallery', { product }) if (isServer) { - syncPromises.push(variantsFilter) syncPromises.push(gallerySetup) } await Promise.all(syncPromises) @@ -673,17 +273,8 @@ const actions: ActionTree = { */ setProductGallery (context, { product }) { - if (product.type_id === 'configurable' && product.hasOwnProperty('configurable_children')) { - if (!config.products.gallery.mergeConfigurableChildren && product.is_configured) { - context.commit(types.PRODUCT_SET_GALLERY, attributeImages(context.getters.getCurrentProduct)) - } else { - let productGallery = uniqBy(configurableChildrenImages(product).concat(getMediaGallery(product)), 'src').filter(f => { return f.src && f.src !== config.images.productPlaceholder }) - context.commit(types.PRODUCT_SET_GALLERY, productGallery) - } - } else { - let productGallery = uniqBy(configurableChildrenImages(product).concat(getMediaGallery(product)), 'src').filter(f => { return f.src && f.src !== config.images.productPlaceholder }) - context.commit(types.PRODUCT_SET_GALLERY, productGallery) - } + const productGallery = getProductGallery(product) + context.commit(types.PRODUCT_SET_GALLERY, productGallery) }, async loadProductBreadcrumbs ({ dispatch, rootGetters }, { product } = {}) { if (product && product.category_ids) { @@ -702,7 +293,29 @@ const actions: ActionTree = { } await dispatch('category-next/loadCategoryBreadcrumbs', { category: breadcrumbCategory, currentRouteName: product.name }, { root: true }) } - } + }, + async getProductVariant (context, { product, configuration } = {}) { + let searchQuery = new SearchQuery() + searchQuery = searchQuery.applyFilter({ key: 'sku', value: { 'eq': product.parentSku } }) + if (!product.parentSku) { + throw new Error('Product doesn\'t have parentSku, please check if this is configurable product') + } + const { items: [newProductVariant] } = await context.dispatch('findProducts', { + query: searchQuery, + size: 1, + configuration, + options: { + fallbackToDefaultWhenNoAvailable: false, + setProductErrors: true, + separateSelectedVariant: true + } + }) + const { selectedVariant = {}, options, product_option } = newProductVariant + + return { ...selectedVariant, options, product_option } + }, + /** Below actions are not used from 1.12 and can be removed to reduce bundle */ + ...require('./deprecatedActions').default } export default actions diff --git a/core/modules/catalog/store/product/deprecatedActions.ts b/core/modules/catalog/store/product/deprecatedActions.ts new file mode 100644 index 000000000..ef8940608 --- /dev/null +++ b/core/modules/catalog/store/product/deprecatedActions.ts @@ -0,0 +1,355 @@ +import { optionLabel } from '../../helpers/optionLabel' +import trim from 'lodash-es/trim' +import { formatBreadCrumbRoutes, isServer } from '@vue-storefront/core/helpers' +import { preConfigureProduct, storeProductToCache, isGroupedOrBundle } from '@vue-storefront/core/modules/catalog/helpers/search' +import toString from 'lodash-es/toString' +import { + registerProductsMapping, + filterOutUnavailableVariants +} from '@vue-storefront/core/modules/catalog/helpers' +import { Logger } from '@vue-storefront/core/lib/logger'; +import * as types from './mutation-types' +import { ProductService } from '@vue-storefront/core/data-resolver/ProductService' +import config from 'config' +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' +const { populateProductConfigurationAsync } = require('@vue-storefront/core/modules/catalog/helpers') + +const actions = { + /** + * Reset current configuration and selected variatnts + */ + reset (context) { + Logger.warn('`product/reset` deprecated, will be not used from 1.12')() + const originalProduct = Object.assign({}, context.getters.getOriginalProduct) + context.commit(types.PRODUCT_RESET_CURRENT, originalProduct) + }, + /** + * Setup product breadcrumbs path + */ + async setupBreadcrumbs (context, { product }) { + Logger.warn('`product/setupBreadcrumbs` deprecated, will be not used from 1.12')() + let breadcrumbsName = null + let setBreadcrumbRoutesFromPath = (path) => { + if (path.findIndex(itm => { + return itm.slug === context.rootGetters['category/getCurrentCategory'].slug + }) < 0) { + path.push({ + url_path: context.rootGetters['category/getCurrentCategory'].url_path, + slug: context.rootGetters['category/getCurrentCategory'].slug, + name: context.rootGetters['category/getCurrentCategory'].name + }) // current category at the end + } + // deprecated, TODO: base on breadcrumbs module + breadcrumbsName = product.name + const breadcrumbs = { + routes: formatBreadCrumbRoutes(path), + current: breadcrumbsName, + name: breadcrumbsName + } + context.commit(types.CATALOG_SET_BREADCRUMBS, breadcrumbs) + } + + if (product.category && product.category.length > 0) { + const categoryIds = product.category.reverse().map(cat => cat.category_id) + await context.dispatch('category/list', { key: 'id', value: categoryIds }, { root: true }).then(async (categories) => { + const catList = [] + + for (let catId of categoryIds) { + let category = categories.items.find((itm) => { return toString(itm['id']) === toString(catId) }) + if (category) { + catList.push(category) + } + } + + const rootCat = catList.shift() + let catForBreadcrumbs = rootCat + + for (let cat of catList) { + const catPath = cat.path + if (catPath && catPath.includes(rootCat.path) && (catPath.split('/').length > catForBreadcrumbs.path.split('/').length)) { + catForBreadcrumbs = cat + } + } + if (typeof catForBreadcrumbs !== 'undefined') { + await context.dispatch('category/single', { key: 'id', value: catForBreadcrumbs.id }, { root: true }).then(() => { // this sets up category path and current category + setBreadcrumbRoutesFromPath(context.rootGetters['category/getCurrentCategoryPath']) + }).catch(err => { + setBreadcrumbRoutesFromPath(context.rootGetters['category/getCurrentCategoryPath']) + Logger.error(err)() + }) + } else { + setBreadcrumbRoutesFromPath(context.rootGetters['category/getCurrentCategoryPath']) + } + }) + } + }, + /** + * Download Magento2 / other platform prices to put them over ElasticSearch prices + */ + async syncPlatformPricesOver ({ rootGetters }, { skus }) { + Logger.warn('`product/syncPlatformPricesOver`deprecated, will be not used from 1.12')() + const result = await ProductService.getProductRenderList({ + skus, + isUserGroupedTaxActive: rootGetters['tax/getIsUserGroupedTaxActive'], + userGroupId: rootGetters['tax/getUserTaxGroupId'], + token: rootGetters['user/getToken'] + }) + return result + }, + /** + * Setup associated products + */ + setupAssociated (context, { product, skipCache = true }) { + Logger.warn('`product/setupAssociated` deprecated, will be not used from 1.12')() + let subloaders = [] + if (product.type_id === 'grouped') { + product.price = 0 + product.price_incl_tax = 0 + Logger.debug(product.name + ' SETUP ASSOCIATED', product.type_id)() + if (product.product_links && product.product_links.length > 0) { + for (let pl of product.product_links) { + if (pl.link_type === 'associated' && pl.linked_product_type === 'simple') { // prefetch links + Logger.debug('Prefetching grouped product link for ' + pl.sku + ' = ' + pl.linked_product_sku)() + subloaders.push(context.dispatch('single', { + options: { sku: pl.linked_product_sku }, + setCurrentProduct: false, + selectDefaultVariant: false, + skipCache: skipCache + }).catch(err => { Logger.error(err) }).then((asocProd) => { + if (asocProd) { + pl.product = asocProd + pl.product.qty = 1 + product.price += pl.product.price + product.price_incl_tax += pl.product.price_incl_tax + product.tax += pl.product.tax + } else { + Logger.error('Product link not found', pl.linked_product_sku)() + } + })) + } + } + } else { + Logger.error('Product with type grouped has no product_links set!', product)() + } + } + if (product.type_id === 'bundle') { + product.price = 0 + product.price_incl_tax = 0 + Logger.debug(product.name + ' SETUP ASSOCIATED', product.type_id)() + if (product.bundle_options && product.bundle_options.length > 0) { + for (let bo of product.bundle_options) { + let defaultOption = bo.product_links.find((p) => { return p.is_default }) + if (!defaultOption) defaultOption = bo.product_links[0] + for (let pl of bo.product_links) { + Logger.debug('Prefetching bundle product link for ' + bo.sku + ' = ' + pl.sku)() + subloaders.push(context.dispatch('single', { + options: { sku: pl.sku }, + setCurrentProduct: false, + selectDefaultVariant: false, + skipCache: skipCache + }).catch(err => { Logger.error(err) }).then((asocProd) => { + if (asocProd) { + pl.product = asocProd + pl.product.qty = pl.qty + + if (pl.id === defaultOption.id) { + product.price += pl.product.price * pl.product.qty + product.price_incl_tax += pl.product.price_incl_tax * pl.product.qty + product.tax += pl.product.tax * pl.product.qty + } + } else { + Logger.error('Product link not found', pl.sku)() + } + })) + } + } + } + } + return Promise.all(subloaders) + }, + /** + * Load required configurable attributes + * @param context + * @param product + */ + loadConfigurableAttributes (context, { product }) { + Logger.warn('`product/loadConfigurableAttributes` deprecated, will be not used from 1.12')() + let attributeKey = 'attribute_id' + const configurableAttrKeys = product.configurable_options.map(opt => { + if (opt.attribute_id) { + attributeKey = 'attribute_id' + return opt.attribute_id + } else { + attributeKey = 'attribute_code' + return opt.attribute_code + } + }) + return context.dispatch('attribute/list', { + filterValues: configurableAttrKeys, + filterField: attributeKey + }, { root: true }) + }, + /** + * Setup product current variants + */ + async setupVariants (context, { product }) { + Logger.warn('`product/setupVariants` deprecated, will be not used from 1.12')() + if (product.type_id !== 'configurable' || !product.hasOwnProperty('configurable_options')) { + return + } + if (config.entities.attribute.loadByAttributeMetadata) { + await context.dispatch('attribute/loadProductAttributes', { products: [product] }, { root: true }) + } + let productOptions = {} + for (let option of product.configurable_options) { + for (let ov of option.values) { + let lb = ov.label ? ov.label : optionLabel(context.rootState.attribute, { attributeKey: option.attribute_id, searchBy: 'id', optionId: ov.value_index }) + if (trim(lb) !== '') { + let optionKey = option.attribute_code ? option.attribute_code : option.label.toLowerCase() + if (!productOptions[optionKey]) { + productOptions[optionKey] = [] + } + + productOptions[optionKey].push({ + label: lb, + id: ov.value_index, + attribute_code: option.attribute_code + }) + } + } + } + context.commit(types.PRODUCT_SET_CURRENT_OPTIONS, productOptions) + let selectedVariant = context.getters.getCurrentProduct + populateProductConfigurationAsync(context, { selectedVariant: selectedVariant, product: product }) + }, + filterUnavailableVariants (context, { product }) { + Logger.warn('`product/filterUnavailableVariants` deprecated, will be not used from 1.12')() + return filterOutUnavailableVariants(context, product) + }, + preConfigureAssociated (context, { searchResult, prefetchGroupProducts }) { + Logger.warn('`product/preConfigureAssociated` deprecated, will be not used from 1.12')() + registerProductsMapping(context, searchResult.items) + for (let product of searchResult.items) { + if (isGroupedOrBundle(product) && prefetchGroupProducts && !isServer) { + context.dispatch('setupAssociated', { product }) + } + } + }, + async preConfigureProduct (context, { product, populateRequestCacheTags, configuration }) { + Logger.warn('`product/preConfigureProduct` deprecated, will be not used from 1.12')() + let _product = preConfigureProduct({ product, populateRequestCacheTags }) + + if (configuration) { + const selectedVariant = await context.dispatch('getProductVariant', { product: _product, configuration }) + _product = Object.assign({}, _product, selectedVariant) + } + + return _product + }, + async configureLoadedProducts (context, { products, isCacheable, cacheByKey, populateRequestCacheTags, configuration }) { + Logger.warn('`product/configureLoadedProducts` deprecated, will be not used from 1.12')() + const configuredProducts = await context.dispatch( + 'category-next/configureProducts', + { + products: products.items, + filters: configuration || {}, + populateRequestCacheTags + }, + { root: true } + ) + + await context.dispatch('tax/calculateTaxes', { products: configuredProducts }, { root: true }) + + for (let product of configuredProducts) { // we store each product separately in cache to have offline access to products/single method + if (isCacheable) { // store cache only for full loads + storeProductToCache(product, cacheByKey) + } + } + + return products + }, + /** + * Update associated products for bundle product + * @param context + * @param product + */ + configureBundleAsync (context, product) { + Logger.warn('`product/configureBundleAsync` deprecated, will be not used from 1.12')() + return context.dispatch( + 'setupAssociated', { + product: product, + skipCache: true + }) + .then(() => { context.dispatch('setCurrent', product) }) + .then(() => { EventBus.$emit('product-after-setup-associated') }) + }, + + /** + * Update associated products for group product + * @param context + * @param product + */ + configureGroupedAsync (context, product) { + Logger.warn('`product/configureGroupedAsync` deprecated, will be not used from 1.12')() + return context.dispatch( + 'setupAssociated', { + product: product, + skipCache: true + }) + .then(() => { context.dispatch('setCurrent', product) }) + }, + /** + * Configure product with given configuration and set it as current + * @param {Object} context + * @param {Object} product + * @param {Array} configuration + */ + async configure (context, { product = null, configuration, selectDefaultVariant = true, fallbackToDefaultWhenNoAvailable = false }) { + Logger.warn('`product/configure` deprecated, will be not used from 1.12, use "product/getProductVariant"')() + const result = await context.dispatch('getProductVariant', { product, configuration }) + return result + }, + + setCurrentOption (context, productOption) { + Logger.warn('`product/setCurrentOption` deprecated, will be not used from 1.12')() + if (productOption && typeof productOption === 'object') { // TODO: this causes some kind of recurrency error + context.commit(types.PRODUCT_SET_CURRENT, Object.assign({}, context.getters.getCurrentProduct, { product_option: productOption })) + } + }, + + setCurrentErrors (context, errors) { + Logger.warn('`product/setCurrentErrors` deprecated, will be not used from 1.12')() + if (errors && typeof errors === 'object') { + context.commit(types.PRODUCT_SET_CURRENT, Object.assign({}, context.getters.getCurrentProduct, { errors: errors })) + } + }, + /** + * Set given product as original + * @param {Object} context + * @param {Object} originalProduct + */ + setOriginal (context, originalProduct) { + Logger.warn('`product/setOriginal` deprecated, will be not used from 1.12')() + if (originalProduct && typeof originalProduct === 'object') context.commit(types.PRODUCT_SET_ORIGINAL, Object.assign({}, originalProduct)) + else Logger.debug('Unable to setup original product.', 'product')() + }, + + /** + * Load product attributes + */ + async loadProductAttributes ({ dispatch }, { product }) { + Logger.warn('`product/loadProductAttributes` deprecated, will be not used from 1.12')() + const productFields = Object.keys(product).filter(fieldName => { + return !config.entities.product.standardSystemFields.includes(fieldName) // don't load metadata info for standard fields + }) + const { product: { useDynamicAttributeLoader }, optimize, attribute } = config.entities + return dispatch('attribute/list', { // load attributes to be shown on the product details - the request is now async + filterValues: useDynamicAttributeLoader ? productFields : null, + only_visible: !!useDynamicAttributeLoader, + only_user_defined: true, + includeFields: optimize ? attribute.includeFields : null + }, { root: true }) + } +} + +export default actions diff --git a/core/modules/catalog/store/product/getters.ts b/core/modules/catalog/store/product/getters.ts index e8d9d4948..700c69420 100644 --- a/core/modules/catalog/store/product/getters.ts +++ b/core/modules/catalog/store/product/getters.ts @@ -1,12 +1,19 @@ import { GetterTree } from 'vuex' import RootState from '@vue-storefront/core/types/RootState' import ProductState from '../../types/ProductState' +import { Logger } from '@vue-storefront/core/lib/logger'; const getters: GetterTree = { getCurrentProduct: state => state.current, getCurrentProductConfiguration: state => state.current_configuration, getCurrentProductOptions: state => state.current_options, - getOriginalProduct: state => state.original, + getOriginalProduct: (state, getters) => { + if (!getters.getCurrentProduct) return null + return state.original || { + ...getters.getCurrentProduct, + id: getters.getCurrentProduct.parentId || getters.getCurrentProduct.id + } + }, getParentProduct: state => state.parent, getProductsSearchResult: state => state.list, getProducts: (state, getters) => getters.getProductsSearchResult.items, diff --git a/core/modules/catalog/store/product/mutations.ts b/core/modules/catalog/store/product/mutations.ts index 2c81ada34..bd415ba0d 100644 --- a/core/modules/catalog/store/product/mutations.ts +++ b/core/modules/catalog/store/product/mutations.ts @@ -1,6 +1,8 @@ import { MutationTree } from 'vuex' +import { Logger } from '@vue-storefront/core/lib/logger' import * as types from './mutation-types' import ProductState, { PagedProductList } from '../../types/ProductState' +import Vue from 'vue' const mutations: MutationTree = { [types.PRODUCT_SET_PAGED_PRODUCTS] (state, searchResult) { @@ -47,7 +49,7 @@ const mutations: MutationTree = { state.current_options = configuration }, [types.PRODUCT_SET_CURRENT_CONFIGURATION] (state, configuration = {}) { - state.current_configuration = configuration + Vue.set(state, 'current_configuration', configuration || {}) }, [types.PRODUCT_SET_ORIGINAL] (state, product) { state.original = product @@ -91,28 +93,28 @@ const mutations: MutationTree = { state.breadcrumbs = payload }, [types.CATALOG_ADD_CUSTOM_OPTION_VALIDATOR] (state, { validationRule, validatorFunction }) { - console.error('Deprecated mutation CATALOG_ADD_CUSTOM_OPTION_VALIDATOR - use PRODUCT_SET_CUSTOM_OPTION_VALIDATOR instead') + Logger.error('Deprecated mutation CATALOG_ADD_CUSTOM_OPTION_VALIDATOR - use PRODUCT_SET_CUSTOM_OPTION_VALIDATOR instead')() }, [types.CATALOG_UPD_RELATED] (state, { key, items }) { - console.error('Deprecated mutation CATALOG_UPD_RELATED - use PRODUCT_SET_RELATED instead') + Logger.error('Deprecated mutation CATALOG_UPD_RELATED - use PRODUCT_SET_RELATED instead')() }, [types.CATALOG_UPD_BUNDLE_OPTION] (state, { optionId, optionQty, optionSelections }) { - console.error('Deprecated mutation CATALOG_UPD_BUNDLE_OPTION - use PRODUCT_SET_BUNDLE_OPTION instead') + Logger.error('Deprecated mutation CATALOG_UPD_BUNDLE_OPTION - use PRODUCT_SET_BUNDLE_OPTION instead')() }, [types.CATALOG_UPD_PRODUCTS] (state, { products, append }) { - console.error('Deprecated mutation CATALOG_UPD_PRODUCTS - use PRODUCT_SET_PAGED_PRODUCTS or PRODUCT_ADD_PAGED_PRODUCTS instead') + Logger.error('Deprecated mutation CATALOG_UPD_PRODUCTS - use PRODUCT_SET_PAGED_PRODUCTS or PRODUCT_ADD_PAGED_PRODUCTS instead')() }, [types.CATALOG_SET_PRODUCT_CURRENT] (state, product) { - console.error('Deprecated mutation CATALOG_SET_PRODUCT_CURRENT - use PRODUCT_SET_CURRENT instead') + Logger.error('Deprecated mutation CATALOG_SET_PRODUCT_CURRENT - use PRODUCT_SET_CURRENT instead')() }, [types.CATALOG_SET_PRODUCT_ORIGINAL] (state, product) { - console.error('Deprecated mutation CATALOG_SET_PRODUCT_ORIGINAL - use PRODUCT_SET_ORIGINAL instead') + Logger.error('Deprecated mutation CATALOG_SET_PRODUCT_ORIGINAL - use PRODUCT_SET_ORIGINAL instead')() }, [types.CATALOG_RESET_PRODUCT] (state, productOriginal) { - console.error('Deprecated mutation CATALOG_RESET_PRODUCT - use PRODUCT_RESET_CURRENT instead') + Logger.error('Deprecated mutation CATALOG_RESET_PRODUCT - use PRODUCT_RESET_CURRENT instead')() }, [types.CATALOG_UPD_GALLERY] (state, productGallery) { - console.error('Deprecated mutation CATALOG_UPD_GALLERY - use PRODUCT_SET_GALLERY instead') + Logger.error('Deprecated mutation CATALOG_UPD_GALLERY - use PRODUCT_SET_GALLERY instead')() } } diff --git a/core/modules/catalog/store/tax/actions.ts b/core/modules/catalog/store/tax/actions.ts index 72faad3ea..420c37613 100644 --- a/core/modules/catalog/store/tax/actions.ts +++ b/core/modules/catalog/store/tax/actions.ts @@ -1,7 +1,7 @@ import { ActionTree } from 'vuex' import * as types from './mutation-types' import { quickSearchByQuery } from '@vue-storefront/core/lib/search' -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' +import { SearchQuery } from 'storefront-query-builder' import RootState from '@vue-storefront/core/types/RootState' import TaxState from '../../types/TaxState' import { Logger } from '@vue-storefront/core/lib/logger' diff --git a/core/modules/catalog/test/helpers/createProduct.ts b/core/modules/catalog/test/helpers/createProduct.ts new file mode 100644 index 000000000..2e3c68918 --- /dev/null +++ b/core/modules/catalog/test/helpers/createProduct.ts @@ -0,0 +1,96 @@ +import Product from '@vue-storefront/core/modules/catalog/types/Product'; + +export const createSimpleProduct = (): Product => ({ + 'id': 21, + 'sku': '24-WG084', + 'name': 'Sprite Foam Yoga Brick', + 'price': 5, + 'status': 1, + 'visibility': 4, + 'type_id': 'simple', + 'product_links': [], + 'custom_attributes': null, + 'final_price': 5, + 'max_price': 5, + 'max_regular_price': 5, + 'minimal_regular_price': 5, + 'special_price': null, + 'minimal_price': 5, + 'regular_price': 5, + 'description': '

Our top-selling yoga prop, the 4-inch, high-quality Sprite Foam Yoga Brick is popular among yoga novices and studio professionals alike. An essential yoga accessory, the yoga brick is a critical tool for finding balance and alignment in many common yoga poses. Choose from 5 color options.

', + 'image': '/l/u/luma-yoga-brick.jpg', + 'small_image': '/l/u/luma-yoga-brick.jpg', + 'thumbnail': '/l/u/luma-yoga-brick.jpg', + 'color': '50', + 'category_ids': [3, 5], + 'url_key': 'sprite-foam-yoga-brick', + 'tax_class_id': '2', + 'slug': 'sprite-foam-yoga-brick-21', + 'media_gallery': [{ 'image': '/l/u/luma-yoga-brick.jpg', 'pos': 1, 'typ': 'image', 'lab': 'Image', 'vid': null }], + 'stock': { 'item_id': 21, 'product_id': 21, 'stock_id': 1, 'qty': 986, 'is_in_stock': true, 'is_qty_decimal': false, 'show_default_notification_message': false, 'use_config_min_qty': true, 'min_qty': 0, 'use_config_min_sale_qty': 1, 'min_sale_qty': 1, 'use_config_max_sale_qty': true, 'max_sale_qty': 10000, 'use_config_backorders': true, 'backorders': 0, 'use_config_notify_stock_qty': true, 'notify_stock_qty': 1, 'use_config_qty_increments': true, 'qty_increments': 0, 'use_config_enable_qty_inc': true, 'enable_qty_increments': false, 'use_config_manage_stock': true, 'manage_stock': true, 'low_stock_date': null, 'is_decimal_divided': false, 'stock_status_changed_auto': 0 }, + 'category': [{ 'category_id': 3, 'name': 'Gear', 'slug': 'gear-3', 'path': 'gear/gear-3' }, { 'category_id': 5, 'name': 'Fitness Equipment', 'slug': 'fitness-equipment-5', 'path': 'gear/fitness-equipment/fitness-equipment-5' }], + 'url_path': 'gear/gear-3/sprite-foam-yoga-brick-21.html' +}) + +export const createGroupProduct = (): Product => ({ + 'id': 2046, + 'sku': '24-WG085_Group', + 'name': 'Set of Sprite Yoga Straps', + 'status': 1, + 'visibility': 4, + 'type_id': 'grouped', + 'product_links': [{ 'sku': '24-WG085_Group', 'link_type': 'associated', 'linked_product_sku': '24-WG085', 'linked_product_type': 'simple', 'position': 0, 'extension_attributes': { 'qty': 20 } }, { 'sku': '24-WG085_Group', 'link_type': 'associated', 'linked_product_sku': '24-WG086', 'linked_product_type': 'simple', 'position': 1, 'extension_attributes': { 'qty': 30 } }, { 'sku': '24-WG085_Group', 'link_type': 'associated', 'linked_product_sku': '24-WG087', 'linked_product_type': 'simple', 'position': 2, 'extension_attributes': { 'qty': 40 } }], + 'custom_attributes': null, + 'final_price': 12, + 'price': 12, + 'max_price': 12, + 'max_regular_price': 12, + 'minimal_regular_price': 12, + 'special_price': null, + 'minimal_price': 12, + 'regular_price': 0, + 'description': '

Great set of Sprite Yoga Straps for every stretch and hold you need. There are three straps in this set: 6', + 'image': '/l/u/luma-yoga-strap-set.jpg', + 'small_image': '/l/u/luma-yoga-strap-set.jpg', + 'thumbnail': '/l/u/luma-yoga-strap-set.jpg', + 'category_ids': [3, 5], + 'url_key': 'set-of-sprite-yoga-straps', + 'slug': 'set-of-sprite-yoga-straps-2046', + 'links': { 'associated': [{ 'sku': '24-WG085', 'pos': 0 }, { 'sku': '24-WG086', 'pos': 1 }, { 'sku': '24-WG087', 'pos': 2 }] }, + 'stock': { 'item_id': 2046, 'product_id': 2046, 'stock_id': 1, 'qty': 0, 'is_in_stock': true, 'is_qty_decimal': false, 'show_default_notification_message': false, 'use_config_min_qty': true, 'min_qty': 0, 'use_config_min_sale_qty': 1, 'min_sale_qty': 1, 'use_config_max_sale_qty': true, 'max_sale_qty': 10000, 'use_config_backorders': true, 'backorders': 0, 'use_config_notify_stock_qty': true, 'notify_stock_qty': 1, 'use_config_qty_increments': true, 'qty_increments': 0, 'use_config_enable_qty_inc': true, 'enable_qty_increments': false, 'use_config_manage_stock': true, 'manage_stock': true, 'low_stock_date': null, 'is_decimal_divided': false, 'stock_status_changed_auto': 0 }, + 'media_gallery': [{ 'image': '/l/u/luma-yoga-strap-set.jpg', 'pos': 1, 'typ': 'image', 'lab': 'Image', 'vid': null }], + 'category': [{ 'category_id': 3, 'name': 'Gear', 'slug': 'gear-3', 'path': 'gear/gear-3' }, { 'category_id': 5, 'name': 'Fitness Equipment', 'slug': 'fitness-equipment-5', 'path': 'gear/fitness-equipment/fitness-equipment-5' }], + 'url_path': 'gear/gear-3/set-of-sprite-yoga-straps-2046.html' +}) + +export const createBundleProduct = (): Product => ({ + 'id': 45, + 'sku': '24-WG080', + 'name': 'Sprite Yoga Companion Kit', + 'price': 0, + 'status': 1, + 'visibility': 4, + 'type_id': 'bundle', + 'product_links': [], + 'custom_attributes': null, + 'final_price': 64, + 'max_price': 77, + 'max_regular_price': 77, + 'minimal_regular_price': 64, + 'special_price': null, + 'minimal_price': 64, + 'regular_price': 64, + 'description': '

A well-rounded yoga workout takes more than a mat. The Sprite Yoga Companion Kit helps stock your studio with the basics you need for a full-range workout. The kit is composed of four best-selling Luma Sprite accessories in one easy bundle: statis ball, foam block, yoga strap, and foam roller. Choose sizes and colors and leave the rest to us. The kit includes:

  • Sprite Statis Ball
  • Sprite Foam Yoga Brick
  • Sprite Yoga Strap
  • Sprite Foam Roller
', + 'image': '/l/u/luma-yoga-kit-2.jpg', + 'small_image': '/l/u/luma-yoga-kit-2.jpg', + 'thumbnail': '/l/u/luma-yoga-kit-2.jpg', + 'category_ids': [3, 5], + 'url_key': 'sprite-yoga-companion-kit', + 'tax_class_id': '2', + 'slug': 'sprite-yoga-companion-kit-45', + 'stock': { 'item_id': 45, 'product_id': 45, 'stock_id': 1, 'qty': 0, 'is_in_stock': true, 'is_qty_decimal': false, 'show_default_notification_message': false, 'use_config_min_qty': true, 'min_qty': 0, 'use_config_min_sale_qty': 1, 'min_sale_qty': 1, 'use_config_max_sale_qty': true, 'max_sale_qty': 10000, 'use_config_backorders': true, 'backorders': 0, 'use_config_notify_stock_qty': true, 'notify_stock_qty': 1, 'use_config_qty_increments': true, 'qty_increments': 0, 'use_config_enable_qty_inc': true, 'enable_qty_increments': false, 'use_config_manage_stock': true, 'manage_stock': true, 'low_stock_date': null, 'is_decimal_divided': false, 'stock_status_changed_auto': 0 }, + 'media_gallery': [{ 'image': '/l/u/luma-yoga-kit-2.jpg', 'pos': 1, 'typ': 'image', 'lab': 'Image', 'vid': null }], + 'bundle_options': [{ 'option_id': 1, 'title': 'Sprite Stasis Ball', 'required': true, 'type': 'radio', 'position': 1, 'sku': '24-WG080', 'product_links': [{ 'id': '1', 'sku': '24-WG081-blue', 'option_id': 1, 'qty': 1, 'position': 1, 'is_default': true, 'price': null, 'price_type': null, 'can_change_quantity': 1 }, { 'id': '2', 'sku': '24-WG082-blue', 'option_id': 1, 'qty': 1, 'position': 2, 'is_default': false, 'price': null, 'price_type': null, 'can_change_quantity': 1 }, { 'id': '3', 'sku': '24-WG083-blue', 'option_id': 1, 'qty': 1, 'position': 3, 'is_default': false, 'price': null, 'price_type': null, 'can_change_quantity': 1 }] }, { 'option_id': 2, 'title': 'Sprite Foam Yoga Brick', 'required': true, 'type': 'radio', 'position': 2, 'sku': '24-WG080', 'product_links': [{ 'id': '4', 'sku': '24-WG084', 'option_id': 2, 'qty': 1, 'position': 1, 'is_default': true, 'price': null, 'price_type': null, 'can_change_quantity': 1 }] }, { 'option_id': 3, 'title': 'Sprite Yoga Strap', 'required': true, 'type': 'radio', 'position': 3, 'sku': '24-WG080', 'product_links': [{ 'id': '5', 'sku': '24-WG085', 'option_id': 3, 'qty': 1, 'position': 1, 'is_default': true, 'price': null, 'price_type': null, 'can_change_quantity': 1 }, { 'id': '6', 'sku': '24-WG086', 'option_id': 3, 'qty': 1, 'position': 2, 'is_default': false, 'price': null, 'price_type': null, 'can_change_quantity': 1 }, { 'id': '7', 'sku': '24-WG087', 'option_id': 3, 'qty': 1, 'position': 3, 'is_default': false, 'price': null, 'price_type': null, 'can_change_quantity': 1 }] }, { 'option_id': 4, 'title': 'Sprite Foam Roller', 'required': true, 'type': 'radio', 'position': 4, 'sku': '24-WG080', 'product_links': [{ 'id': '8', 'sku': '24-WG088', 'option_id': 4, 'qty': 1, 'position': 1, 'is_default': true, 'price': null, 'price_type': null, 'can_change_quantity': 1 }] }], + 'category': [{ 'category_id': 3, 'name': 'Gear', 'slug': 'gear-3', 'path': 'gear/gear-3' }, { 'category_id': 5, 'name': 'Fitness Equipment', 'slug': 'fitness-equipment-5', 'path': 'gear/fitness-equipment/fitness-equipment-5' }], + 'url_path': 'gear/gear-3/sprite-yoga-companion-kit-45.html' +}) diff --git a/core/modules/catalog/test/unit/helpers/associatedProducts/setBundleProduct.spec.ts b/core/modules/catalog/test/unit/helpers/associatedProducts/setBundleProduct.spec.ts new file mode 100644 index 000000000..edb960af1 --- /dev/null +++ b/core/modules/catalog/test/unit/helpers/associatedProducts/setBundleProduct.spec.ts @@ -0,0 +1,54 @@ +import { createBundleProduct, createGroupProduct, createSimpleProduct } from '../../../helpers/createProduct'; +import setBundleProducts from '@vue-storefront/core/modules/catalog/helpers/associatedProducts/setBundleProducts'; +import { ProductService } from '@vue-storefront/core/data-resolver/ProductService' + +jest.mock('@vue-storefront/core/helpers', () => ({ + once: (str) => jest.fn() +})) +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + error: jest.fn(() => () => {}) + } +})); +jest.mock('@vue-storefront/core/store', () => ({})); +jest.mock('@vue-storefront/core/data-resolver/ProductService', () => ({ + ProductService: { + getProducts: jest.fn(), + getProductRenderList: jest.fn(), + getProductByKey: jest.fn() + } +})); + +describe('setBundleProducts helper', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(ProductService.getProducts as jest.Mock).mockImplementation(async () => ({ items: [] })); + }) + it('should not fire ProductService.getProducts if it is not bundle product', async () => { + const groupedProduct = createGroupProduct() + + setBundleProducts(groupedProduct) + + expect(ProductService.getProducts).toHaveBeenCalledTimes(0) + }) + it('should fire ProductService.getProducts with simple configuration', async () => { + const bundleProduct = createBundleProduct() + + setBundleProducts(bundleProduct) + + expect(ProductService.getProducts).toHaveBeenNthCalledWith(1, { + query: expect.anything(), + excludeFields: null, + includeFields: null, + options: { + prefetchGroupProducts: false, + fallbackToDefaultWhenNoAvailable: false, + setProductErrors: false, + setConfigurableProductOptions: false, + assignProductConfiguration: false, + separateSelectedVariant: false + } + }) + }) +}) diff --git a/core/modules/catalog/test/unit/helpers/associatedProducts/setGroupedProduct.spec.ts b/core/modules/catalog/test/unit/helpers/associatedProducts/setGroupedProduct.spec.ts new file mode 100644 index 000000000..752de2cc1 --- /dev/null +++ b/core/modules/catalog/test/unit/helpers/associatedProducts/setGroupedProduct.spec.ts @@ -0,0 +1,56 @@ +import { createBundleProduct, createGroupProduct, createSimpleProduct } from '../../../helpers/createProduct'; +import setGroupedProduct from '@vue-storefront/core/modules/catalog/helpers/associatedProducts/setGroupedProduct'; +import setProductLink from '@vue-storefront/core/modules/catalog/helpers/associatedProducts/setProductLink'; +import { ProductService } from '@vue-storefront/core/data-resolver/ProductService' + +jest.mock('@vue-storefront/core/helpers', () => ({ + once: (str) => jest.fn() +})) +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + error: jest.fn(() => () => {}) + } +})); +jest.mock('@vue-storefront/core/store', () => ({})); +jest.mock('@vue-storefront/core/data-resolver/ProductService', () => ({ + ProductService: { + getProducts: jest.fn(), + getProductRenderList: jest.fn(), + getProductByKey: jest.fn() + } +})); +jest.mock('@vue-storefront/core/modules/catalog/helpers/associatedProducts/setProductLink', () => jest.fn()); + +describe('setGroupedProduct helper', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(ProductService.getProducts as jest.Mock).mockImplementation(async () => ({ items: [] })); + }) + it('should not fire ProductService.getProducts if it is not grouped product', async () => { + const bundleProduct = createBundleProduct() + + setGroupedProduct(bundleProduct) + + expect(ProductService.getProducts).toHaveBeenCalledTimes(0) + }) + it('should fire ProductService.getProducts with simple configuration', async () => { + const groupedProduct = createGroupProduct() + + setGroupedProduct(groupedProduct) + + expect(ProductService.getProducts).toHaveBeenNthCalledWith(1, { + query: expect.anything(), + excludeFields: null, + includeFields: null, + options: { + prefetchGroupProducts: false, + fallbackToDefaultWhenNoAvailable: false, + setProductErrors: false, + setConfigurableProductOptions: false, + assignProductConfiguration: false, + separateSelectedVariant: false + } + }) + }) +}) diff --git a/core/modules/catalog/test/unit/helpers/associatedProducts/setProductLink.spec.ts b/core/modules/catalog/test/unit/helpers/associatedProducts/setProductLink.spec.ts new file mode 100644 index 000000000..0dd2cff21 --- /dev/null +++ b/core/modules/catalog/test/unit/helpers/associatedProducts/setProductLink.spec.ts @@ -0,0 +1,44 @@ +import { createBundleProduct, createGroupProduct, createSimpleProduct } from '../../../helpers/createProduct'; +import setProductLink from '@vue-storefront/core/modules/catalog/helpers/associatedProducts/setProductLink'; + +jest.mock('@vue-storefront/core/helpers', () => ({ + once: (str) => jest.fn() +})) +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + error: jest.fn(() => () => {}) + } +})); +jest.mock('@vue-storefront/core/store', () => ({})); + +describe('setProductLink helper', () => { + it('should add product if associatedProduct exist for bundle link', async () => { + const bundleProduct = createBundleProduct() + const productLink = bundleProduct.bundle_options[0].product_links[0] + const simpleProduct = createSimpleProduct() + + setProductLink(productLink, simpleProduct) + + expect(productLink.product).toStrictEqual(simpleProduct) + expect(productLink.product.qty).toBe(1) + }) + it('should add product if associatedProduct exist for group link', async () => { + const groupProduct = createGroupProduct() + const productLink = groupProduct.product_links[0] + const simpleProduct = createSimpleProduct() + + setProductLink(productLink, simpleProduct) + + expect(productLink.product).toStrictEqual(simpleProduct) + expect(productLink.product.qty).toBe(1) + }) + it('should not add product if associatedProduct doesn\'t exist', async () => { + const groupProduct = createGroupProduct() + const productLink = groupProduct.product_links[0] + + setProductLink(productLink, null) + + expect(productLink.product).toBe(undefined) + }) +}) diff --git a/core/modules/catalog/test/unit/store/product/actions/findProducts.spec.ts b/core/modules/catalog/test/unit/store/product/actions/findProducts.spec.ts new file mode 100644 index 000000000..f9aad29f2 --- /dev/null +++ b/core/modules/catalog/test/unit/store/product/actions/findProducts.spec.ts @@ -0,0 +1,187 @@ +import { DataResolver } from '@vue-storefront/core/data-resolver/types/DataResolver'; +import productActions from '@vue-storefront/core/modules/catalog/store/product/actions'; +import config from 'config'; +import { ProductService } from '@vue-storefront/core/data-resolver/ProductService' +import { registerProductsMapping, setRequestCacheTags } from '@vue-storefront/core/modules/catalog/helpers' + +jest.mock('@vue-storefront/core/helpers', () => ({ + once: (str) => jest.fn() +})) +jest.mock('@vue-storefront/core/modules/catalog/helpers', () => ({ + registerProductsMapping: jest.fn(), + setRequestCacheTags: jest.fn() +})) + +jest.mock('@vue-storefront/core/store', () => ({ + dispatch: jest.fn(), + state: {} +})); +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + log: jest.fn(() => () => {}), + debug: jest.fn(() => () => {}), + warn: jest.fn(() => () => {}), + error: jest.fn(() => () => {}), + info: jest.fn(() => () => {}) + } +})); +jest.mock('@vue-storefront/core/compatibility/plugins/event-bus', () => ({ + $emit: jest.fn() +})); +jest.mock('@vue-storefront/core/data-resolver/ProductService', () => ({ + ProductService: { + getProducts: jest.fn(), + getProductRenderList: jest.fn(), + getProductByKey: jest.fn() + } +})); +jest.mock('config', () => ({})); +jest.mock('@vue-storefront/core/modules/catalog/events', () => ({ + checkParentRedirection: (str) => jest.fn() +})) + +describe('product/findProducts action', () => { + let contextMock + let items + beforeEach(() => { + jest.clearAllMocks() + contextMock = { + dispatch: jest.fn(() => ({})) + } + items = [{ url_path: 'dsada', sku: 'dsad' }] + config.cart = { + setConfigurableProductOptions: true + } + config.products = { + filterUnavailableVariants: false + } + ;(ProductService.getProducts as jest.Mock).mockImplementation(async () => ({ items })); + }) + it('should trigger ProductService.getProducts with default values', async () => { + const wrapper = (actions: any) => actions.findProducts(contextMock) + + await wrapper(productActions) + + expect(ProductService.getProducts).toHaveBeenNthCalledWith(1, { + query: undefined, + start: 0, + size: 50, + sort: '', + excludeFields: null, + includeFields: null, + configuration: null, + options: { + prefetchGroupProducts: true, + setProductErrors: false, + fallbackToDefaultWhenNoAvailable: true, + assignProductConfiguration: false, + separateSelectedVariant: false, + setConfigurableProductOptions: true, + filterUnavailableVariants: false + } + }) + }) + + it('should trigger ProductService.getProducts with provided values', async () => { + const wrapper = (actions: any) => actions.findProducts(contextMock, { + query: { test: 'test' }, + start: 123, + size: 1221, + sort: 'test', + excludeFields: ['test'], + includeFields: ['test'], + configuration: { test: 'test' }, + populateRequestCacheTags: true, + options: { + populateRequestCacheTags: true, + prefetchGroupProducts: false, + setProductErrors: true, + fallbackToDefaultWhenNoAvailable: false, + assignProductConfiguration: true, + separateSelectedVariant: true, + setConfigurableProductOptions: false, + filterUnavailableVariants: false + } + }) + + await wrapper(productActions) + + expect(ProductService.getProducts).toHaveBeenNthCalledWith(1, { + query: { test: 'test' }, + start: 123, + size: 1221, + sort: 'test', + excludeFields: ['test'], + includeFields: ['test'], + configuration: { test: 'test' }, + options: { + prefetchGroupProducts: false, + setProductErrors: true, + fallbackToDefaultWhenNoAvailable: false, + assignProductConfiguration: true, + separateSelectedVariant: true, + setConfigurableProductOptions: false, + filterUnavailableVariants: false + } + }) + }) + it('should register mapping for products returned from ProductService.getProducts', async () => { + const wrapper = (actions: any) => actions.findProducts(contextMock) + + await wrapper(productActions) + + expect(registerProductsMapping).toHaveBeenNthCalledWith(1, contextMock, items) + }) + it('should not set cache tags if populateRequestCacheTags or options.populateRequestCacheTags is false', async () => { + const wrapper = (actions: any) => actions.findProducts(contextMock) + + await wrapper(productActions) + + expect(setRequestCacheTags).toHaveBeenCalledTimes(0) + }) + it('should set cache tags if populateRequestCacheTags is true', async () => { + const wrapper = (actions: any) => actions.findProducts(contextMock, { populateRequestCacheTags: true }) + + await wrapper(productActions) + + expect(setRequestCacheTags).toHaveBeenNthCalledWith(1, { products: items }) + }) + it('should set cache tags if options.populateRequestCacheTags is true', async () => { + const wrapper = (actions: any) => actions.findProducts(contextMock, { options: { populateRequestCacheTags: true } }) + + await wrapper(productActions) + + expect(setRequestCacheTags).toHaveBeenNthCalledWith(1, { products: items }) + }) + it('should mutatate prices by triggering tax/calculateTaxes', async () => { + const wrapper = (actions: any) => actions.findProducts(contextMock) + + await wrapper(productActions) + + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'tax/calculateTaxes', { products: items }, { root: true }) + }) + it('should return items and rest response data as one object', async () => { + ;(ProductService.getProducts as jest.Mock).mockImplementation(async () => ({ + items, + perPage: 1, + start: 2, + total: 3, + aggregations: [], + attributeMetadata: [] + })); + + const wrapper = (actions: any) => actions.findProducts(contextMock) + + const result: DataResolver.ProductsListResponse = await wrapper(productActions) + + expect(result).toStrictEqual({ + items, + perPage: 1, + start: 2, + total: 3, + aggregations: [], + attributeMetadata: [] + }) + }) +}) diff --git a/core/modules/catalog/test/unit/store/product/actions/getProductVariant.spec.ts b/core/modules/catalog/test/unit/store/product/actions/getProductVariant.spec.ts new file mode 100644 index 000000000..7d9b9e8ab --- /dev/null +++ b/core/modules/catalog/test/unit/store/product/actions/getProductVariant.spec.ts @@ -0,0 +1,70 @@ +import productActions from '@vue-storefront/core/modules/catalog/store/product/actions'; + +jest.mock('@vue-storefront/core/helpers', () => ({ + once: (str) => jest.fn() +})) +jest.mock('@vue-storefront/core/store', () => ({ + dispatch: jest.fn() +})); +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + log: jest.fn(() => () => {}), + debug: jest.fn(() => () => {}), + warn: jest.fn(() => () => {}), + error: jest.fn(() => () => {}), + info: jest.fn(() => () => {}) + } +})); +jest.mock('config', () => ({})); +jest.mock('@vue-storefront/core/modules/catalog/events', () => ({ + checkParentRedirection: (str) => jest.fn() +})) + +describe('product/getProductVariant action', () => { + let contextMock + beforeEach(() => { + jest.clearAllMocks() + contextMock = { + dispatch: jest.fn(() => ({ items: ['test'] })), + commit: jest.fn(() => ({})) + } + }) + it('should throw error if no arguments is provided', async () => { + const wrapper = (actions: any) => actions.getProductVariant(contextMock) + await expect(wrapper(productActions)).rejects.toThrow(expect.anything()) + }) + it('should throw error if product doesn\'t have parentSku', async () => { + const wrapper = (actions: any) => actions.getProductVariant(contextMock, { product: { sku: 'sku' } }) + await expect(wrapper(productActions)).rejects.toThrow(expect.anything()) + }) + + it('should dispatch findProducts', async () => { + const wrapper = (actions: any) => actions.getProductVariant(contextMock, { product: { parentSku: 'sku' } }) + + await wrapper(productActions) + + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'findProducts', { + query: expect.anything(), + size: 1, + configuration: undefined, + options: { + fallbackToDefaultWhenNoAvailable: false, + setProductErrors: true, + separateSelectedVariant: true + } + }) + }) + + it('should return options and product_option beside variant data', async () => { + contextMock = { + dispatch: jest.fn(() => ({ items: [{ sku: 'sku', options: ['test'], product_option: ['test2'] }] })) + } + const wrapper = (actions: any) => actions.getProductVariant(contextMock, { product: { parentSku: 'sku' } }) + + const result = await wrapper(productActions) + + expect(result.options).toStrictEqual(['test']) + expect(result.product_option).toStrictEqual(['test2']) + }) +}) diff --git a/core/modules/catalog/test/unit/store/product/actions/list.spec.ts b/core/modules/catalog/test/unit/store/product/actions/list.spec.ts new file mode 100644 index 000000000..f9c9dd1d2 --- /dev/null +++ b/core/modules/catalog/test/unit/store/product/actions/list.spec.ts @@ -0,0 +1,137 @@ +import productActions from '@vue-storefront/core/modules/catalog/store/product/actions'; +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' +import * as mutationTypes from '@vue-storefront/core/modules/catalog/store/product/mutation-types' + +jest.mock('@vue-storefront/core/helpers', () => ({ + once: (str) => jest.fn() +})) + +jest.mock('@vue-storefront/core/store', () => ({ + dispatch: jest.fn(), + state: {} +})); +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + log: jest.fn(() => () => {}), + debug: jest.fn(() => () => {}), + warn: jest.fn(() => () => {}), + error: jest.fn(() => () => {}), + info: jest.fn(() => () => {}) + } +})); +jest.mock('@vue-storefront/core/compatibility/plugins/event-bus', () => ({ + $emit: jest.fn() +})); +jest.mock('config', () => ({})); +jest.mock('@vue-storefront/core/modules/catalog/events', () => ({ + checkParentRedirection: (str) => jest.fn() +})) + +describe('product/list action', () => { + it('should dispatch findProducts with default values for list', async () => { + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(() => ({ items: ['test'] })) + } + const wrapper = (actions: any) => actions.list(contextMock) + + await wrapper(productActions) + + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'findProducts', { + query: undefined, + start: 0, + size: 50, + sort: '', + excludeFields: null, + includeFields: null, + configuration: null, + options: { + populateRequestCacheTags: true, + prefetchGroupProducts: true + } + }) + }) + + it('should dispatch findProducts with provided values for list', async () => { + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(() => ({ items: ['test'] })) + } + const wrapper = (actions: any) => actions.list(contextMock, { + query: { test: 'test' }, + start: 1, + size: 10, + sort: 'final_price', + excludeFields: [], + includeFields: [], + configuration: { test: 'test' }, + populateRequestCacheTags: false, + prefetchGroupProducts: false + }) + + await wrapper(productActions) + + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'findProducts', { + query: { test: 'test' }, + start: 1, + size: 10, + sort: 'final_price', + excludeFields: [], + includeFields: [], + configuration: { test: 'test' }, + options: { + populateRequestCacheTags: false, + prefetchGroupProducts: false + } + }) + }) + + it('should emit "product-after-list" event', async () => { + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(() => ({ items: ['test'] })) + } + const wrapper = (actions: any) => actions.list(contextMock) + + await wrapper(productActions) + + expect(EventBus.$emit).toHaveBeenCalledWith('product-after-list', expect.anything()) + }) + + it('should not update state by deafult', async () => { + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(() => ({ items: ['test'] })) + } + const wrapper = (actions: any) => actions.list(contextMock) + + await wrapper(productActions) + + expect(contextMock.commit).toHaveBeenCalledTimes(0) + }) + + it('should not append state by deafult if update store', async () => { + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(() => ({ items: ['test'] })) + } + const wrapper = (actions: any) => actions.list(contextMock, { updateState: true }) + + await wrapper(productActions) + + expect(contextMock.commit).toHaveBeenCalledWith(mutationTypes.PRODUCT_SET_PAGED_PRODUCTS, { items: ['test'] }) + }) + + it('should append state', async () => { + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(() => ({ items: ['test'] })) + } + const wrapper = (actions: any) => actions.list(contextMock, { updateState: true, append: true }) + + await wrapper(productActions) + + expect(contextMock.commit).toHaveBeenCalledWith(mutationTypes.PRODUCT_ADD_PAGED_PRODUCTS, { items: ['test'] }) + }) +}) diff --git a/core/modules/catalog/test/unit/store/product/actions/setCurrent.spec.ts b/core/modules/catalog/test/unit/store/product/actions/setCurrent.spec.ts new file mode 100644 index 000000000..774840a4d --- /dev/null +++ b/core/modules/catalog/test/unit/store/product/actions/setCurrent.spec.ts @@ -0,0 +1,82 @@ +import productActions from '@vue-storefront/core/modules/catalog/store/product/actions'; +import config from 'config'; +import * as mutationTypes from '@vue-storefront/core/modules/catalog/store/product/mutation-types' +import { getProductConfigurationOptions } from '@vue-storefront/core/modules/catalog/helpers/productOptions' + +jest.mock('@vue-storefront/core/helpers', () => ({ + once: (str) => jest.fn() +})) +jest.mock('@vue-storefront/core/store', () => ({ + dispatch: jest.fn(), + commit: jest.fn() +})); +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + log: jest.fn(() => () => {}), + debug: jest.fn(() => () => {}), + warn: jest.fn(() => () => {}), + error: jest.fn(() => () => {}), + info: jest.fn(() => () => {}) + } +})); +jest.mock('config', () => ({})); +jest.mock('@vue-storefront/core/modules/catalog/helpers/productOptions', () => ({ + getProductConfigurationOptions: jest.fn() +})); +jest.mock('@vue-storefront/core/modules/catalog/events', () => ({ + checkParentRedirection: (str) => jest.fn() +})) + +describe('product/setCurrent action', () => { + let contextMock + let product + beforeEach(() => { + jest.clearAllMocks() + contextMock = { + dispatch: jest.fn(() => ({})), + commit: jest.fn(() => ({})), + rootState: { + attribute: {} + } + } + config.products = { + gallery: { + mergeConfigurableChildren: false + } + } + product = { + sku: 'sku', + configuration: { color: 42 } + } + }) + it('should return if no product provided', async () => { + const wrapper = (actions: any) => actions.setCurrent(contextMock) + await wrapper(productActions) + expect(contextMock.dispatch).toBeCalledTimes(0) + expect(contextMock.commit).toBeCalledTimes(0) + }) + it('should commit product data and configuration', async () => { + ;(getProductConfigurationOptions as jest.Mock).mockImplementation(() => ({ color: [{ attribute_code: 'color', id: '42', label: 'Green' }] })); + const wrapper = (actions: any) => actions.setCurrent(contextMock, product) + await wrapper(productActions) + expect(contextMock.commit).toHaveBeenNthCalledWith(1, mutationTypes.PRODUCT_SET_CURRENT_OPTIONS, { color: [{ attribute_code: 'color', id: '42', label: 'Green' }] }) + expect(contextMock.commit).toHaveBeenNthCalledWith(2, mutationTypes.PRODUCT_SET_CURRENT_CONFIGURATION, { color: 42 }) + expect(contextMock.commit).toHaveBeenNthCalledWith(3, mutationTypes.PRODUCT_SET_CURRENT, { sku: 'sku' }) + }) + it('should call setProductGallery if mergeConfigurableChildren is set false', async () => { + const wrapper = (actions: any) => actions.setCurrent(contextMock, product) + await wrapper(productActions) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'setProductGallery', { product: { sku: 'sku' } }) + }) + it('should not call setProductGallery if mergeConfigurableChildren is set true', async () => { + config.products = { + gallery: { + mergeConfigurableChildren: true + } + } + const wrapper = (actions: any) => actions.setCurrent(contextMock, product) + await wrapper(productActions) + expect(contextMock.dispatch).toHaveBeenCalledTimes(0) + }) +}) diff --git a/core/modules/catalog/test/unit/store/product/actions/single.spec.ts b/core/modules/catalog/test/unit/store/product/actions/single.spec.ts new file mode 100644 index 000000000..468ad22ea --- /dev/null +++ b/core/modules/catalog/test/unit/store/product/actions/single.spec.ts @@ -0,0 +1,98 @@ +import productActions from '@vue-storefront/core/modules/catalog/store/product/actions'; +import config from 'config'; +import { ProductService } from '@vue-storefront/core/data-resolver/ProductService' +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' + +jest.mock('@vue-storefront/core/helpers', () => ({ + once: (str) => jest.fn() +})) +jest.mock('@vue-storefront/core/modules/catalog/helpers', () => ({ + registerProductsMapping: jest.fn(), + setRequestCacheTags: jest.fn() +})) + +jest.mock('@vue-storefront/core/store', () => ({ + dispatch: jest.fn(), + state: {} +})); +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + log: jest.fn(() => () => {}), + debug: jest.fn(() => () => {}), + warn: jest.fn(() => () => {}), + error: jest.fn(() => () => {}), + info: jest.fn(() => () => {}) + } +})); +jest.mock('@vue-storefront/core/compatibility/plugins/event-bus', () => ({ + $emit: jest.fn(), + $emitFilter: jest.fn() +})); +jest.mock('@vue-storefront/core/data-resolver/ProductService', () => ({ + ProductService: { + getProducts: jest.fn(), + getProductRenderList: jest.fn(), + getProductByKey: jest.fn() + } +})); +jest.mock('config', () => ({})); +jest.mock('@vue-storefront/core/modules/catalog/events', () => ({ + checkParentRedirection: (str) => jest.fn() +})) + +describe('product/single action', () => { + let contextMock + let product + let options + beforeEach(() => { + jest.clearAllMocks() + contextMock = { + dispatch: jest.fn(() => ({})) + } + options = { sku: 'sku' } + product = [{ url_path: 'dsada', sku: 'dsad' }] + config.cart = { + setConfigurableProductOptions: true + } + config.products = { + filterUnavailableVariants: false + } + ;(ProductService.getProductByKey as jest.Mock).mockImplementation(async () => product); + }) + it('should throw error if there is no option value based on key', async () => { + const wrapper = (actions: any) => actions.single(contextMock) + + await expect(wrapper(productActions)).rejects.toThrow('Please provide the search key sku for product/single action!') + }) + it('should trigger ProductService.getProductByKey with default values', async () => { + const wrapper = (actions: any) => actions.single(contextMock, { options }) + await wrapper(productActions) + expect(ProductService.getProductByKey).toHaveBeenCalledWith({ + options: { sku: 'sku' }, + key: 'sku', + skipCache: false + }) + }) + it('should mutatate prices by triggering tax/calculateTaxes', async () => { + const wrapper = (actions: any) => actions.single(contextMock, { options }) + + await wrapper(productActions) + + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'tax/calculateTaxes', { products: [product] }, { root: true }) + }) + it('should set current product', async () => { + const wrapper = (actions: any) => actions.single(contextMock, { options, setCurrentProduct: true }) + + await wrapper(productActions) + + expect(contextMock.dispatch).toHaveBeenNthCalledWith(2, 'setCurrent', product) + }) + it('should emit "product-after-single" event', async () => { + const wrapper = (actions: any) => actions.single(contextMock, { options }) + + await wrapper(productActions) + + expect(EventBus.$emitFilter).toHaveBeenCalledWith('product-after-single', expect.anything()) + }) +}) diff --git a/core/modules/catalog/types/Attribute.ts b/core/modules/catalog/types/Attribute.ts index 0a4851ecc..85baf9711 100644 --- a/core/modules/catalog/types/Attribute.ts +++ b/core/modules/catalog/types/Attribute.ts @@ -2,3 +2,22 @@ export default interface Attribute { attribute_code?: string, attribute_id?: number | string } + +export interface AttributesMetadata { + is_visible_on_front: string, + is_visible: boolean, + default_frontend_label: string, + attribute_id: number, + entity_type_id: string, + id: number, + frontend_input: string, + is_user_defined: boolean, + is_comparable: string, + attribute_code: string, + options: AttributesMetadataOptions[] +} + +export interface AttributesMetadataOptions { + label: string, + value: string +} diff --git a/core/modules/catalog/types/BundleOption.ts b/core/modules/catalog/types/BundleOption.ts new file mode 100644 index 000000000..b2d9b3809 --- /dev/null +++ b/core/modules/catalog/types/BundleOption.ts @@ -0,0 +1,30 @@ +import Product from '@vue-storefront/core/modules/catalog/types/Product'; + +export interface BundleOption { + option_id: number, + title: string, + required: boolean, + type: string, + position: number, + sku: string, + product_links: BundleOptionsProductLink[] +} + +export interface BundleOptionsProductLink { + id: string | number, + sku: string, + option_id: number, + qty: number, + position: number, + is_default: boolean, + price?: number, + price_type?: number, + can_change_quantity: number, + product?: Product +} + +export interface SelectedBundleOption { + option_id: number, + option_qty: number, + option_selections: number[] +} diff --git a/core/modules/catalog/types/ConfigurableOption.ts b/core/modules/catalog/types/ConfigurableOption.ts new file mode 100644 index 000000000..854464d43 --- /dev/null +++ b/core/modules/catalog/types/ConfigurableOption.ts @@ -0,0 +1,6 @@ +export interface ConfigurableItemOption { + label: string, + option_id: string, + option_value: string, + value: string +} diff --git a/core/modules/catalog/types/Product.ts b/core/modules/catalog/types/Product.ts index 7bb815d3c..5f53bf254 100644 --- a/core/modules/catalog/types/Product.ts +++ b/core/modules/catalog/types/Product.ts @@ -1,18 +1,25 @@ +import { ProductOption } from './ProductConfiguration'; +import { ConfigurableItemOption } from './ConfigurableOption'; +import { BundleOption, SelectedBundleOption } from './BundleOption'; +import { AttributesMetadata } from './Attribute'; import { CustomOption } from './CustomOption'; export default interface Product { + attributes_metadata?: AttributesMetadata[], + bundle_options?: BundleOption[], category: Record[], - category_ids: string[], - color: string, + category_ids: string[] | number[], + color?: string, color_options?: number[] | string[], - configurable_children: Record[], - configurable_options: Record[], + configurable_children?: Record[], + configurable_options?: ProductOption[], custom_attributes?: any, + custom_options?: CustomOption[], description: string, errors?: Record, - final_price: number, - finalPrice: number, - gift_message_available: string, + final_price?: number, + finalPrice?: number, + gift_message_available?: string, has_options?: string, id?: number | string, image: string, @@ -34,13 +41,13 @@ export default interface Product { priceInclTax?: number, price_tax?: number, priceTax?: number, - product_links?: Record[], - product_option?: Record, + product_links?: ProductLink[], + product_option?: ProductOptions, regular_price: number, required_options?: string, sale?: string, sgn?: string, - size: string, + size?: string, size_options?: number[] | string[], sku: string, slug?: string, @@ -59,8 +66,31 @@ export default interface Product { tsk?: number, type_id: string, url_key: string, + url_path?: string, visibility: number, _score?: number, qty?: number, - custom_options?: CustomOption + tier_prices?: any[], + links?: any, + parentId?: number | string +} + +export interface ProductLink { + sku: string, + link_type: string, + linked_product_sku: string, + linked_product_type: string, + position: number, + extension_attributes: { + qty: number + }, + product?: Product +} + +export interface ProductOptions { + extension_attributes: { + custom_options: any[], + configurable_item_options: ConfigurableItemOption[], + bundle_options: SelectedBundleOption[] + } } diff --git a/core/modules/catalog/types/ProductConfiguration.ts b/core/modules/catalog/types/ProductConfiguration.ts index b5965b54b..e820335f2 100644 --- a/core/modules/catalog/types/ProductConfiguration.ts +++ b/core/modules/catalog/types/ProductConfiguration.ts @@ -1,7 +1,8 @@ export interface ProductOption { attribute_code?: string, id: number | string, - label: string + label: string, + values?: any[] } export interface ProductConfiguration { diff --git a/core/modules/checkout/components/Shipping.ts b/core/modules/checkout/components/Shipping.ts index d34af6a37..a8a93097e 100644 --- a/core/modules/checkout/components/Shipping.ts +++ b/core/modules/checkout/components/Shipping.ts @@ -121,7 +121,7 @@ export const Shipping = { }, useMyAddress () { if (this.shipToMyAddress) { - this.shipping = { + this.$set(this, 'shipping', { firstName: this.myAddressDetails.firstname, lastName: this.myAddressDetails.lastname, country: this.myAddressDetails.country_id, @@ -133,9 +133,9 @@ export const Shipping = { phoneNumber: this.myAddressDetails.telephone, shippingMethod: this.checkoutShippingDetails.shippingMethod, shippingCarrier: this.checkoutShippingDetails.shippingCarrier - } + }) } else { - this.shipping = this.checkoutShippingDetails + this.$set(this, 'shipping', this.checkoutShippingDetails) } this.changeCountry() }, diff --git a/core/modules/checkout/index.ts b/core/modules/checkout/index.ts index 18047616b..899e547fa 100644 --- a/core/modules/checkout/index.ts +++ b/core/modules/checkout/index.ts @@ -2,6 +2,7 @@ import { StorefrontModule } from '@vue-storefront/core/lib/modules' import { checkoutModule } from './store/checkout' import { paymentModule } from './store/payment' import { shippingModule } from './store/shipping' +import { Logger } from '@vue-storefront/core/lib/logger' import * as types from './store/checkout/mutation-types' import { StorageManager } from '@vue-storefront/core/lib/storage-manager' @@ -19,7 +20,7 @@ export const CheckoutModule: StorefrontModule = function ({ store }) { type.endsWith(types.CHECKOUT_SAVE_PERSONAL_DETAILS) ) { StorageManager.get('checkout').setItem('personal-details', state.checkout.personalDetails).catch((reason) => { - console.error(reason) // it doesn't work on SSR + Logger.error(reason)() // it doesn't work on SSR }) // populate cache } @@ -27,7 +28,7 @@ export const CheckoutModule: StorefrontModule = function ({ store }) { type.endsWith(types.CHECKOUT_SAVE_SHIPPING_DETAILS) || type.endsWith(types.CHECKOUT_UPDATE_PROP_VALUE) ) { StorageManager.get('checkout').setItem('shipping-details', state.checkout.shippingDetails).catch((reason) => { - console.error(reason) // it doesn't work on SSR + Logger.error(reason)() // it doesn't work on SSR }) // populate cache } @@ -35,7 +36,7 @@ export const CheckoutModule: StorefrontModule = function ({ store }) { type.endsWith(types.CHECKOUT_SAVE_PAYMENT_DETAILS) ) { StorageManager.get('checkout').setItem('payment-details', state.checkout.paymentDetails).catch((reason) => { - console.error(reason) // it doesn't work on SSR + Logger.error(reason)() // it doesn't work on SSR }) // populate cache } }) diff --git a/core/modules/cms/helpers/createHierarchyLoadQuery.ts b/core/modules/cms/helpers/createHierarchyLoadQuery.ts index af47fa051..671072069 100644 --- a/core/modules/cms/helpers/createHierarchyLoadQuery.ts +++ b/core/modules/cms/helpers/createHierarchyLoadQuery.ts @@ -1,4 +1,4 @@ -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' +import { SearchQuery } from 'storefront-query-builder' const createHierarchyLoadQuery = ({ id }): SearchQuery => { let query = new SearchQuery() diff --git a/core/modules/cms/helpers/createLoadingBlockQuery.ts b/core/modules/cms/helpers/createLoadingBlockQuery.ts index 02ea841a9..8cbb30530 100644 --- a/core/modules/cms/helpers/createLoadingBlockQuery.ts +++ b/core/modules/cms/helpers/createLoadingBlockQuery.ts @@ -1,4 +1,4 @@ -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' +import { SearchQuery } from 'storefront-query-builder' const createLoadingBlockQuery = ({ filterField, filterValues }): SearchQuery => { let query = new SearchQuery() diff --git a/core/modules/cms/helpers/createPageLoadingQuery.ts b/core/modules/cms/helpers/createPageLoadingQuery.ts index 3c1a03f81..b93f514fe 100644 --- a/core/modules/cms/helpers/createPageLoadingQuery.ts +++ b/core/modules/cms/helpers/createPageLoadingQuery.ts @@ -1,4 +1,4 @@ -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' +import { SearchQuery } from 'storefront-query-builder' const createPageLoadingQuery = ({ filterField, filterValues }): SearchQuery => { let query = new SearchQuery() diff --git a/core/modules/cms/helpers/createSingleBlockQuery.ts b/core/modules/cms/helpers/createSingleBlockQuery.ts index 3f975105a..1277a5d76 100644 --- a/core/modules/cms/helpers/createSingleBlockQuery.ts +++ b/core/modules/cms/helpers/createSingleBlockQuery.ts @@ -1,4 +1,4 @@ -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' +import { SearchQuery } from 'storefront-query-builder' const createSingleBlockQuery = ({ key, value }): SearchQuery => { let query = new SearchQuery() diff --git a/core/modules/cms/helpers/createSinglePageLoadQuery.ts b/core/modules/cms/helpers/createSinglePageLoadQuery.ts index 79463bc57..ba7db2e08 100644 --- a/core/modules/cms/helpers/createSinglePageLoadQuery.ts +++ b/core/modules/cms/helpers/createSinglePageLoadQuery.ts @@ -1,4 +1,4 @@ -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' +import { SearchQuery } from 'storefront-query-builder' const createSinglePageLoadQuery = ({ key, value }): SearchQuery => { let query = new SearchQuery() diff --git a/core/modules/cms/test/unit/createHierarchyLoadQuery.spec.ts b/core/modules/cms/test/unit/createHierarchyLoadQuery.spec.ts index 09b7495cc..b21c9494a 100644 --- a/core/modules/cms/test/unit/createHierarchyLoadQuery.spec.ts +++ b/core/modules/cms/test/unit/createHierarchyLoadQuery.spec.ts @@ -8,7 +8,7 @@ describe('createHierarchyLoadQuery', () => { expect(loadQuery).toHaveProperty('_availableFilters') expect(loadQuery).toHaveProperty('_searchText') - let [ appliedFilter ] = loadQuery._appliedFilters + let [ appliedFilter ] = loadQuery.getAppliedFilters() expect(appliedFilter).toHaveProperty('value', { eq: filter.id }) expect(appliedFilter).toHaveProperty('attribute', 'identifier') @@ -20,6 +20,6 @@ describe('createHierarchyLoadQuery', () => { const filter = { id: null } let hierarchyLoadQuery = createHierarchyLoadQuery(filter) - expect(hierarchyLoadQuery).toEqual({ _availableFilters: [], _appliedFilters: [], _searchText: '' }) + expect(hierarchyLoadQuery).toEqual({ _availableFilters: [], _appliedFilters: [], _appliedSort: [], _searchText: '' }) }) }) diff --git a/core/modules/cms/test/unit/createLoadingBlockQuery.spec.ts b/core/modules/cms/test/unit/createLoadingBlockQuery.spec.ts index ceb4edb14..4673de015 100644 --- a/core/modules/cms/test/unit/createLoadingBlockQuery.spec.ts +++ b/core/modules/cms/test/unit/createLoadingBlockQuery.spec.ts @@ -5,7 +5,7 @@ describe('createLoadingBlockQuery', () => { const filter = { filterField: 'test', filterValues: ['test1', 'test2'] } let loadingBlockQuery = createLoadingBlockQuery(filter) - let [ appliedFilter ] = loadingBlockQuery._appliedFilters + let [ appliedFilter ] = loadingBlockQuery.getAppliedFilters() expect(appliedFilter).toHaveProperty('attribute', filter.filterField) expect(appliedFilter).toHaveProperty('value', { like: filter.filterValues }) @@ -15,6 +15,6 @@ describe('createLoadingBlockQuery', () => { const filter = { filterField: 'test', filterValues: undefined } let loadingBlockQuery = createLoadingBlockQuery(filter) - expect(loadingBlockQuery).toEqual({ _availableFilters: [], _appliedFilters: [], _searchText: '' }) + expect(loadingBlockQuery).toEqual({ _availableFilters: [], _appliedFilters: [], _appliedSort: [], _searchText: '' }) }) }) diff --git a/core/modules/cms/test/unit/createPageLoadingQuery.spec.ts b/core/modules/cms/test/unit/createPageLoadingQuery.spec.ts index 290e9fb96..471fe1ac5 100644 --- a/core/modules/cms/test/unit/createPageLoadingQuery.spec.ts +++ b/core/modules/cms/test/unit/createPageLoadingQuery.spec.ts @@ -5,7 +5,7 @@ describe('createPageLoadingQuery', () => { const filter = { filterField: 'test', filterValues: ['test1', 'test2'] } let pageLoadingQuery = createPageLoadingQuery(filter) - let [ appliedFilter ] = pageLoadingQuery._appliedFilters + let [ appliedFilter ] = pageLoadingQuery.getAppliedFilters() expect(appliedFilter).toHaveProperty('attribute', filter.filterField) expect(appliedFilter).toHaveProperty('value', { like: filter.filterValues }) @@ -15,6 +15,6 @@ describe('createPageLoadingQuery', () => { const filter = { filterField: 'test', filterValues: undefined } let pageLoadingQuery = createPageLoadingQuery(filter) - expect(pageLoadingQuery).toEqual({ _availableFilters: [], _appliedFilters: [], _searchText: '' }) + expect(pageLoadingQuery).toEqual({ _availableFilters: [], _appliedFilters: [], _appliedSort: [], _searchText: '' }) }) }) diff --git a/core/modules/cms/test/unit/createSingleBlockQuery.spec.ts b/core/modules/cms/test/unit/createSingleBlockQuery.spec.ts index 8409621e2..feca385fa 100644 --- a/core/modules/cms/test/unit/createSingleBlockQuery.spec.ts +++ b/core/modules/cms/test/unit/createSingleBlockQuery.spec.ts @@ -5,7 +5,7 @@ describe('createSingleBlockLoadQuery should', () => { const argsMock = { key: 'test', value: ['test1', 'test2'] } let mockSingleBlockQuery = createSingleBlockQuery(argsMock) - let [ appliedFilter ] = mockSingleBlockQuery._appliedFilters + let [ appliedFilter ] = mockSingleBlockQuery.getAppliedFilters() expect(appliedFilter).toHaveProperty('attribute', argsMock.key) expect(appliedFilter).toHaveProperty('value', { like: argsMock.value }) @@ -15,6 +15,6 @@ describe('createSingleBlockLoadQuery should', () => { const argsMock = { key: 'test', value: undefined } let mockSingleBlockQuery = createSingleBlockQuery(argsMock) - expect(mockSingleBlockQuery).toEqual({ _availableFilters: [], _appliedFilters: [], _searchText: '' }) + expect(mockSingleBlockQuery).toEqual({ _availableFilters: [], _appliedFilters: [], _appliedSort: [], _searchText: '' }) }) }) diff --git a/core/modules/cms/test/unit/createSinglePageLoadQuery.spec.ts b/core/modules/cms/test/unit/createSinglePageLoadQuery.spec.ts index ab518b7d9..343b3d4b4 100644 --- a/core/modules/cms/test/unit/createSinglePageLoadQuery.spec.ts +++ b/core/modules/cms/test/unit/createSinglePageLoadQuery.spec.ts @@ -5,7 +5,7 @@ describe('createSinglePageLoadQuery should', () => { const filter = { key: 'test', value: ['test1', 'test2'] } let singlePageMockQuery = createSinglePageLoadQuery(filter) - let [ appliedFilter ] = singlePageMockQuery._appliedFilters + let [ appliedFilter ] = singlePageMockQuery.getAppliedFilters() expect(appliedFilter).toHaveProperty('attribute', filter.key) expect(appliedFilter).toHaveProperty('value', { like: filter.value }) @@ -15,6 +15,6 @@ describe('createSinglePageLoadQuery should', () => { const filter = { key: 'test', value: undefined } let singlePageMockQuery = createSinglePageLoadQuery(filter) - expect(singlePageMockQuery).toEqual({ _availableFilters: [], _appliedFilters: [], _searchText: '' }) + expect(singlePageMockQuery).toEqual({ _availableFilters: [], _appliedFilters: [], _appliedSort: [], _searchText: '' }) }) }) diff --git a/core/modules/compare/components/Compare.ts b/core/modules/compare/components/Compare.ts index c2782fcc8..1a231eda8 100644 --- a/core/modules/compare/components/Compare.ts +++ b/core/modules/compare/components/Compare.ts @@ -1,6 +1,7 @@ import { mapGetters } from 'vuex' import Product from '@vue-storefront/core/modules/catalog/types/Product' import compareMountedMixin from '@vue-storefront/core/modules/compare/mixins/compareMountedMixin' +import config from 'config' export const Compare = { name: 'Compare', @@ -12,10 +13,12 @@ export const Compare = { }) }, created () { - this.$store.dispatch('attribute/list', { - filterValues: [], - filterField: 'is_user_defined' - }) + if (!config.entities.attribute.loadByAttributeMetadata) { + this.$store.dispatch('attribute/list', { + filterValues: [], + filterField: 'is_user_defined' + }) + } }, methods: { removeFromCompare (product: Product) { diff --git a/core/modules/compare/store/actions.ts b/core/modules/compare/store/actions.ts index 37f2718b8..d930385f6 100644 --- a/core/modules/compare/store/actions.ts +++ b/core/modules/compare/store/actions.ts @@ -4,16 +4,26 @@ import RootState from '@vue-storefront/core/types/RootState' import CompareState from '../types/CompareState' import { StorageManager } from '@vue-storefront/core/lib/storage-manager' import { Logger } from '@vue-storefront/core/lib/logger' +import config from 'config' const actions: ActionTree = { async load ({ commit, getters, dispatch }, force: boolean = false) { - if (!force && getters.isCompareLoaded) return - commit(types.SET_COMPARE_LOADED) - const storedItems = await dispatch('fetchCurrentCompare') + if (force || !getters.isCompareLoaded) { + commit(types.SET_COMPARE_LOADED) + const storedItems = await dispatch('fetchCurrentCompare') - if (storedItems) { - commit(types.COMPARE_LOAD_COMPARE, storedItems) - Logger.info('Compare state loaded from browser cache: ', 'cache', storedItems)() + if (storedItems) { + commit(types.COMPARE_LOAD_COMPARE, storedItems) + Logger.info('Compare state loaded from browser cache: ', 'cache', storedItems)() + } + + if (config.entities.attribute.loadByAttributeMetadata) { + dispatch( + 'attribute/loadProductAttributes', + { products: getters.getCompareItems, merge: true }, + { root: true } + ) + } } }, async fetchCurrentCompare () { diff --git a/core/modules/compare/test/unit/components/Compare.spec.ts b/core/modules/compare/test/unit/components/Compare.spec.ts index e31b56e62..6053af315 100644 --- a/core/modules/compare/test/unit/components/Compare.spec.ts +++ b/core/modules/compare/test/unit/components/Compare.spec.ts @@ -1,10 +1,12 @@ import { mountMixinWithStore } from '@vue-storefront/unit-tests/utils'; import { Compare } from '../../../components/Compare' +import config from 'config' jest.mock('@vue-storefront/core/helpers', () => ({ once: jest.fn() })); jest.mock('@vue-storefront/core/modules/compare/mixins/compareMountedMixin', () => ({})) +jest.mock('config', () => ({})); describe('Compare', () => { it('Compare dispatches attribute list action on created', () => { @@ -19,6 +21,12 @@ describe('Compare', () => { } }; + config.entities = { + attribute: { + loadByAttributeMetadata: false + } + } + mountMixinWithStore(Compare, storeMock); expect(storeMock.modules.attribute.actions.list).toBeCalledWith(expect.anything(), { diff --git a/core/modules/initial-resources/clientResourcesLoader.ts b/core/modules/initial-resources/clientResourcesLoader.ts new file mode 100644 index 000000000..99a301266 --- /dev/null +++ b/core/modules/initial-resources/clientResourcesLoader.ts @@ -0,0 +1,47 @@ +import { addRegexpListToConfig, createRegexpMatcher, flatToRegexpList } from './helpers'; +import config from 'config' + +const initialResources = addRegexpListToConfig(config) + +const prefetchRegexps = flatToRegexpList( + initialResources.filter(filterConfig => filterConfig.rel !== 'preload' && filterConfig.onload) +) +const preloadRegexps = flatToRegexpList( + initialResources.filter(filterConfig => filterConfig.rel === 'preload' && filterConfig.onload) +) + +/** + * Build links that need to be load and add them on the end of head. + */ +const addLinksFromManifest = (manifestFilesUrls: string[], regexps: RegExp[], publicPath: string) => { + manifestFilesUrls + .filter((file) => createRegexpMatcher(file)(regexps)) + .forEach((file) => { + const link = document.createElement('link') + link.href = publicPath + file + link.rel = 'prefetch' + + document.head.appendChild(link) + }) +} + +const getManifest = async () => { + let ssrManifest = null + try { + ssrManifest = (await (await fetch('/dist/vue-ssr-client-manifest.json')).json()) || null + } catch (_) { + ssrManifest = null + } + return ssrManifest +} + +/** + * Add links from manifest to head element. + */ +export default async () => { + const ssrManifest = await getManifest() + if (!ssrManifest) return + + addLinksFromManifest(ssrManifest.async, prefetchRegexps, ssrManifest.publicPath) + addLinksFromManifest(ssrManifest.initial, preloadRegexps, ssrManifest.publicPath) +} diff --git a/core/modules/initial-resources/helpers.ts b/core/modules/initial-resources/helpers.ts new file mode 100644 index 000000000..38ab5be57 --- /dev/null +++ b/core/modules/initial-resources/helpers.ts @@ -0,0 +1,52 @@ +import { InitialResources } from './types'; + +/** + * Creates RegExp based on type and provided filter/filname in config. + * There is option to create custom regexp by not providing type. + * @param type - type of resources + * @param filter - part of regex that will filter files from prefetch or preload + */ +const createRegexp = (type, filter = ''): RegExp => { + switch (type) { + case 'script': { + return new RegExp(`^${filter}(\\..+\\.|\\.)js`, 'gi') + } + case 'style': { + return new RegExp(`^${filter}(\\..+\\.|\\.)css`, 'gi') + } + default: { + return new RegExp(`${filter}`, 'gi') + } + } +} + +/** + * Create RegExp list based on initialResources config + */ +const createRegexpList = ({ type, filters = [] }: InitialResources): RegExp[] => filters.map(createRegexp.bind(null, type)) + +/** + * Returns function that require RegExp list. Then after second call we will get boolean that determines if file match any regexp. + * @param file - this is filename that will be checked + */ +export const createRegexpMatcher = (file: string) => (regexps: RegExp[]): boolean => regexps.some(regexp => file.match(regexp)) + +/** + * Extended initialResurces config by adding to it list of RegExp. + */ +export const addRegexpListToConfig = (config): InitialResources[] => { + const initialResourcesConfig: InitialResources[] = config.initialResources || [] + + return initialResourcesConfig + .map(resourceConfig => ({ + ...resourceConfig, + regexps: createRegexpList(resourceConfig) + })) +} + +/** + * Returns RegExp list from extended initialResources config. + */ +export const flatToRegexpList = (configs: InitialResources[]) => configs + .map(pConfig => pConfig.regexps) + .reduce((acc, val) => acc.concat(val), []) diff --git a/core/modules/initial-resources/index.ts b/core/modules/initial-resources/index.ts new file mode 100644 index 000000000..630019f0a --- /dev/null +++ b/core/modules/initial-resources/index.ts @@ -0,0 +1,8 @@ +import { isServer } from '@vue-storefront/core/helpers' +import { StorefrontModule } from '@vue-storefront/core/lib/modules' +import clientResourcesLoader from './clientResourcesLoader' + +export const InitialResourcesModule: StorefrontModule = function () { + if (isServer) return + window.addEventListener('load', clientResourcesLoader) +} diff --git a/core/modules/initial-resources/serverResourcesFilter.ts b/core/modules/initial-resources/serverResourcesFilter.ts new file mode 100644 index 000000000..e0167c5aa --- /dev/null +++ b/core/modules/initial-resources/serverResourcesFilter.ts @@ -0,0 +1,36 @@ +import { addRegexpListToConfig, createRegexpMatcher, flatToRegexpList } from './helpers'; + +const config = require('config') +const initialResources = addRegexpListToConfig(config) + +const prefetchRegexps = flatToRegexpList( + initialResources.filter(filterConfig => filterConfig.rel !== 'preload') +) +/** + * vue-ssr method that filters prefetch files based on initialResources config + */ +export const shouldPrefetch = (file: string) => { + if (prefetchRegexps.length) { + const checkRegexpList = createRegexpMatcher(file) + const matchFilter = checkRegexpList(prefetchRegexps) + + return !matchFilter + } + return true +} + +const preloadRegexps = flatToRegexpList( + initialResources.filter(filterConfig => filterConfig.rel === 'preload') +) +/** + * vue-ssr method that filters preload files based on initialResources config + */ +export const shouldPreload = (file: string) => { + if (preloadRegexps.length) { + const checkRegexpList = createRegexpMatcher(file) + const matchFilter = checkRegexpList(preloadRegexps) + + return !matchFilter + } + return true +} diff --git a/core/modules/initial-resources/types.ts b/core/modules/initial-resources/types.ts new file mode 100644 index 000000000..e31f3319e --- /dev/null +++ b/core/modules/initial-resources/types.ts @@ -0,0 +1,7 @@ +export interface InitialResources { + filters: string[], + regexps?: RegExp[], + type?: 'script' | 'style', + onload?: boolean, + rel?: 'prefetch' | 'preload' +} diff --git a/core/modules/mailer/store/index.ts b/core/modules/mailer/store/index.ts index aad78a607..6f505af09 100644 --- a/core/modules/mailer/store/index.ts +++ b/core/modules/mailer/store/index.ts @@ -3,13 +3,14 @@ import MailItem from '../types/MailItem' import { Module } from 'vuex' import config from 'config' import { processURLAddress } from '@vue-storefront/core/helpers' +import getApiEndpointUrl from '@vue-storefront/core/helpers/getApiEndpointUrl'; export const mailerStore: Module = { namespaced: true, actions: { async sendEmail (context, letter: MailItem) { try { - const res = await fetch(processURLAddress(config.mailer.endpoint.token)) + const res = await fetch(processURLAddress(getApiEndpointUrl(config.mailer.endpoint, 'token'))) const resData = await res.json() if (resData.code === 200) { try { diff --git a/core/modules/order/components/UserOrders.ts b/core/modules/order/components/UserOrders.ts index 56dfcb1f9..fdf4f38b2 100644 --- a/core/modules/order/components/UserOrders.ts +++ b/core/modules/order/components/UserOrders.ts @@ -19,7 +19,7 @@ export const UserOrders = { this.$bus.$emit('notification-progress-start', this.$t('Please wait ...')) const productsToAdd = [] for (const item of products) { - const product = await this.$store.dispatch('product/single', { options: { sku: item.sku }, setCurrentProduct: false, selectDefaultVariant: false }) + const product = await this.$store.dispatch('product/single', { options: { sku: item.sku } }) product.qty = item.qty_ordered productsToAdd.push(product) } diff --git a/core/modules/order/components/UserSingleOrder.ts b/core/modules/order/components/UserSingleOrder.ts index 273c36cec..07b245979 100644 --- a/core/modules/order/components/UserSingleOrder.ts +++ b/core/modules/order/components/UserSingleOrder.ts @@ -36,7 +36,7 @@ export const UserSingleOrder = { this.$bus.$emit('notification-progress-start', this.$t('Please wait ...')) const productsToAdd = [] for (const item of products) { - const product = await this.$store.dispatch('product/single', { options: { sku: item.sku }, setCurrentProduct: false, selectDefaultVariant: false }) + const product = await this.$store.dispatch('product/single', { options: { sku: item.sku } }) product.qty = item.qty_ordered productsToAdd.push(product) } diff --git a/core/modules/order/store/actions.ts b/core/modules/order/store/actions.ts index 4c141a717..21f304c43 100644 --- a/core/modules/order/store/actions.ts +++ b/core/modules/order/store/actions.ts @@ -44,7 +44,7 @@ const actions: ActionTree = { EventBus.$emit('notification-progress-start', i18n.t('Processing order...')) try { - return dispatch('processOrder', { newOrder: order, currentOrderHash }) + return await dispatch('processOrder', { newOrder: order, currentOrderHash }) } catch (error) { dispatch('handlePlacingOrderFailed', { newOrder: order, currentOrderHash }) throw error diff --git a/core/modules/recently-viewed/store/plugin.ts b/core/modules/recently-viewed/store/plugin.ts index aa7cd8a95..fc6a91df7 100644 --- a/core/modules/recently-viewed/store/plugin.ts +++ b/core/modules/recently-viewed/store/plugin.ts @@ -7,7 +7,7 @@ export function plugin (mutation, state) { if (type.startsWith(types.SN_RECENTLY_VIEWED)) { // check if this mutation is recently-viewed related cacheStorage.setItem('recently-viewed', state['recently-viewed'].items).catch((reason) => { - console.error(reason) // it doesn't work on SSR + Logger.error(reason)() // it doesn't work on SSR }) } } diff --git a/core/modules/review/helpers/createLoadReviewsQuery.ts b/core/modules/review/helpers/createLoadReviewsQuery.ts index 8e127dfeb..8a1de303d 100644 --- a/core/modules/review/helpers/createLoadReviewsQuery.ts +++ b/core/modules/review/helpers/createLoadReviewsQuery.ts @@ -1,4 +1,4 @@ -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' +import { SearchQuery } from 'storefront-query-builder' const createLoadReviewsQuery = ({ productId, approved }) => { let query = new SearchQuery() diff --git a/core/modules/review/test/unit/helpers/createLoadReviewsQuery.spec.ts b/core/modules/review/test/unit/helpers/createLoadReviewsQuery.spec.ts index 73c2d2652..cce730194 100644 --- a/core/modules/review/test/unit/helpers/createLoadReviewsQuery.spec.ts +++ b/core/modules/review/test/unit/helpers/createLoadReviewsQuery.spec.ts @@ -4,7 +4,11 @@ const SearchQuery = { applyFilter: jest.fn(() => SearchQuery) } -jest.mock('@vue-storefront/core/lib/search/searchQuery', () => () => SearchQuery) +jest.mock('storefront-query-builder', () => ({ + SearchQuery: function () { + return SearchQuery + } +})); describe('createLoadReviewsQuery', () => { beforeEach(() => { diff --git a/core/modules/review/test/unit/store/actions.spec.ts b/core/modules/review/test/unit/store/actions.spec.ts index 9d67aa46c..af5c438da 100644 --- a/core/modules/review/test/unit/store/actions.spec.ts +++ b/core/modules/review/test/unit/store/actions.spec.ts @@ -3,7 +3,7 @@ import reviewActions from '../../../store/actions'; import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' import { createLoadReviewsQuery } from '@vue-storefront/core/modules/review/helpers' import { quickSearchByQuery } from '@vue-storefront/core/lib/search' -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' +import { SearchQuery } from 'storefront-query-builder' import { ReviewsService } from '@vue-storefront/core/data-resolver' jest.mock('@vue-storefront/core/helpers', () => ({ @@ -27,9 +27,6 @@ jest.mock('@vue-storefront/core/lib/search', () => ({ jest.mock('@vue-storefront/core/modules/review/helpers', () => ({ createLoadReviewsQuery: jest.fn() })); -jest.mock('@vue-storefront/core/lib/search/searchQuery', () => ({ - SearchQuery: jest.fn() -})); jest.mock('@vue-storefront/core/lib/sync', () => ({ TaskQueue: { execute: jest.fn(() => Promise.resolve({ code: 200 })) diff --git a/core/modules/url/helpers/index.ts b/core/modules/url/helpers/index.ts index c00bc3fad..3b0562c35 100644 --- a/core/modules/url/helpers/index.ts +++ b/core/modules/url/helpers/index.ts @@ -102,7 +102,7 @@ export function formatProductLink ( return localizedDispatcherRoute(routeData, storeCode) } else { const routeData: LocalizedRoute = { - name: product.type_id + '-product', + name: product.type_id + '-product', // we should use here localizedDispatcherRouteName? params: { parentSku: product.parentSku ? product.parentSku : product.sku, slug: product.slug, diff --git a/core/modules/url/helpers/transformUrl.ts b/core/modules/url/helpers/transformUrl.ts new file mode 100644 index 000000000..8f30c7990 --- /dev/null +++ b/core/modules/url/helpers/transformUrl.ts @@ -0,0 +1,32 @@ +import { localizedDispatcherRouteName, currentStoreView } from '@vue-storefront/core/lib/multistore'; + +export const transformProductUrl = (product, urlParams = {}) => { + const { storeCode, appendStoreCode } = currentStoreView() + return { + name: localizedDispatcherRouteName(product.type_id + '-product', storeCode, appendStoreCode), + params: { + slug: product.slug, + parentSku: product.parentSku || product.sku, + childSku: urlParams['childSku'] ? urlParams['childSku'] : product.sku + } + } +} + +export const transformCategoryUrl = (category) => { + const { storeCode, appendStoreCode } = currentStoreView() + return { + name: localizedDispatcherRouteName('category', storeCode, appendStoreCode), + params: { + slug: category.slug + } + } +} + +export const transformCmsPageUrl = (cmsPage) => { + return { + name: 'cms-page', + params: { + slug: cmsPage.identifier + } + } +} diff --git a/core/modules/url/store/actions.ts b/core/modules/url/store/actions.ts index 276c70597..efbd456a1 100644 --- a/core/modules/url/store/actions.ts +++ b/core/modules/url/store/actions.ts @@ -1,23 +1,33 @@ +import { transformProductUrl, transformCategoryUrl, transformCmsPageUrl } from '@vue-storefront/core/modules/url/helpers/transformUrl'; import { isServer } from '@vue-storefront/core/helpers'; import { UrlState } from '../types/UrlState' import { ActionTree } from 'vuex'; -import * as types from './mutation-types' // you can use this storage if you want to enable offline capabilities import { cacheStorage } from '../' import queryString from 'query-string' import config from 'config' -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' +import { SearchQuery } from 'storefront-query-builder' import { preProcessDynamicRoutes, normalizeUrlPath, parametrizeRouteData, getFallbackRouteData } from '../helpers' -import { removeStoreCodeFromRoute, currentStoreView, localizedDispatcherRouteName } from '@vue-storefront/core/lib/multistore' +import { removeStoreCodeFromRoute, currentStoreView, localizedDispatcherRouteName, adjustMultistoreApiUrl } from '@vue-storefront/core/lib/multistore' import storeCodeFromRoute from '@vue-storefront/core/lib/storeCodeFromRoute' +import fetch from 'isomorphic-fetch' +import { Logger } from '@vue-storefront/core/lib/logger' +import { processURLAddress } from '@vue-storefront/core/helpers'; +import * as categoryMutationTypes from '@vue-storefront/core/modules/catalog-next/store/category/mutation-types' +import * as cmsPageMutationTypes from '@vue-storefront/core/modules/cms/store/page/mutation-types' import isEqual from 'lodash-es/isEqual' +import * as types from './mutation-types' import omit from 'lodash-es/omit' +import { storeProductToCache } from '@vue-storefront/core/modules/catalog/helpers/search'; +import { prepareProducts } from '@vue-storefront/core/modules/catalog/helpers/prepare'; // it's a good practice for all actions to return Promises with effect of their execution export const actions: ActionTree = { // if you want to use cache in your module you can load cached data like this - async registerMapping ({ commit }, { url, routeData }: { url: string, routeData: any}) { - commit(types.REGISTER_MAPPING, { url, routeData }) + async registerMapping ({ state }, { url, routeData }: { url: string, routeData: any}) { + if (!state.dispatcherMap[url]) { + state.dispatcherMap[url] = routeData + } try { await cacheStorage.setItem(normalizeUrlPath(url), routeData, null, config.seo.disableUrlRoutesPersistentCache) } catch (err) { @@ -55,7 +65,8 @@ export const actions: ActionTree = { if (routeData !== null) { return resolve(parametrizeRouteData(routeData, query, storeCodeInPath)) } else { - dispatch('mappingFallback', { url, params: parsedQuery }).then(mappedFallback => { + const mappingActionName = config.urlModule.enableMapFallbackUrl ? 'mapFallbackUrl' : 'mappingFallback' + dispatch(mappingActionName, { url, params: parsedQuery }).then(mappedFallback => { const routeData = getFallbackRouteData({ mappedFallback, url }) dispatch('registerMapping', { url, routeData }) // register mapping for further usage resolve(parametrizeRouteData(routeData, query, storeCodeInPath)) @@ -64,43 +75,155 @@ export const actions: ActionTree = { }).catch(reject) }) }, - /** + * @deprecated from 1.12 * Router mapping fallback - get the proper URL from API * This method could be overriden in custom module to provide custom URL mapping logic */ async mappingFallback ({ dispatch }, { url, params }: { url: string, params: any}) { - const { storeCode, appendStoreCode } = currentStoreView() + Logger.warn(` + Deprecated action mappingFallback - use mapFallbackUrl instead. + You can enable mapFallbackUrl by changing 'config.urlModule.enableMapFallbackUrl' to true + `)() const productQuery = new SearchQuery() url = (removeStoreCodeFromRoute(url.startsWith('/') ? url.slice(1) : url) as string) productQuery.applyFilter({ key: 'url_path', value: { 'eq': url } }) // Tees category const products = await dispatch('product/list', { query: productQuery }, { root: true }) if (products && products.items && products.items.length) { const product = products.items[0] - return { - name: localizedDispatcherRouteName(product.type_id + '-product', storeCode, appendStoreCode), - params: { - slug: product.slug, - parentSku: product.sku, - childSku: params['childSku'] ? params['childSku'] : product.sku - } - } + return transformProductUrl(product, params) } else { const category = await dispatch('category/single', { key: 'url_path', value: url }, { root: true }) if (category !== null) { + return transformCategoryUrl(category) + } + } + }, + /** + * Router mapping fallback - get the proper URL from API + * This method could be overriden in custom module to provide custom URL mapping logic + */ + async mapFallbackUrl ({ dispatch }, { url, params }: { url: string, params: any}) { + url = (removeStoreCodeFromRoute(url.startsWith('/') ? url.slice(1) : url) as string) + + // search for record in ES based on `url` + const fallbackData = await dispatch('getFallbackByUrl', { url, params }) + + // if there is record in ES then map data + if (fallbackData) { + const [result] = await Promise.all([ + dispatch('transformFallback', { ...fallbackData, params }), + dispatch('saveFallbackData', fallbackData) + ]) + return result + } + + return { + name: 'page-not-found', + params: { + slug: 'page-not-found' + } + } + }, + /** + * Search for record in ES which contains url value (check which fields it searches in vsf-api config.urlModule.map.searchedFields) + */ + async getFallbackByUrl (context, { url, params }) { + const groupId = (config.usePriceTiers && context.rootState.user.groupId) || null + const groupToken = context.rootState.user.groupToken || null + try { + const requestUrl = `${adjustMultistoreApiUrl(processURLAddress(config.urlModule.map_endpoint))}` + let response: any = await fetch( + requestUrl, + { + method: 'POST', + mode: 'cors', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + url, + includeFields: null, // send `includeFields: null || undefined` to fetch all fields + excludeFields: [], + options: { + prefetchGroupProducts: true, + assignProductConfiguration: true, + populateRequestCacheTags: false, + setProductErrors: false, + fallbackToDefaultWhenNoAvailable: true, + separateSelectedVariant: false, + setConfigurableProductOptions: config.cart.setConfigurableProductOptions, + filterUnavailableVariants: config.products.filterUnavailableVariants + }, + filters: { sku: params.childSku }, + groupId, + groupToken + }) + } + ) + if (!response.ok) { + return null + } + response = await response.json() + return response + } catch (err) { + Logger.error('FetchError in request to ES: ', 'search', err)() + return null + } + }, + /** + * Transforms data to vue-router route format + */ + async transformFallback (context, { _type, _source, params }) { + switch (_type) { + case 'product': { + return transformProductUrl(_source, params) + } + case 'category': { + return transformCategoryUrl(_source) + } + case 'cms_page': { + return transformCmsPageUrl(_source) + } + default: { return { - name: localizedDispatcherRouteName('category', storeCode, appendStoreCode), + name: 'page-not-found', params: { - slug: category.slug + slug: 'page-not-found' } } } } }, + /** + * Here we can save data based on _type, so there will be no need to create another request for it. + */ + async saveFallbackData ({ commit }, { _type, _source }) { + switch (_type) { + case 'product': { + const [product] = prepareProducts([_source]) + storeProductToCache(product, 'sku') + break + } + case 'category': { + commit('category-next/' + categoryMutationTypes.CATEGORY_ADD_CATEGORY, _source, { root: true }) + break + } + case 'cms_page': { + commit('cmsPage/' + cmsPageMutationTypes.CMS_PAGE_ADD_CMS_PAGE, _source, { root: true }) + commit('cmsPage/' + cmsPageMutationTypes.CMS_PAGE_SET_CURRENT, _source, { root: true }) + break + } + default: { + break + } + } + }, setCurrentRoute ({ commit, state, rootGetters }, { to, from } = {}) { commit(types.SET_CURRENT_ROUTE, { ...to, - scrollPosition: {...state.prevRoute.scrollPosition}, + scrollPosition: { ...state.prevRoute.scrollPosition }, categoryPageSize: state.prevRoute.categoryPageSize }) diff --git a/core/modules/url/store/index.ts b/core/modules/url/store/index.ts index 315e3fc21..12101dfff 100644 --- a/core/modules/url/store/index.ts +++ b/core/modules/url/store/index.ts @@ -1,14 +1,14 @@ import { Module } from 'vuex' import { UrlState } from '../types/UrlState' -import { mutations } from './mutations' import { actions } from './actions' import { state } from './state' import { getters } from './getters' +import { mutations } from './mutations' export const urlStore: Module = { namespaced: true, - mutations, actions, state, - getters + getters, + mutations } diff --git a/core/modules/url/store/mutation-types.ts b/core/modules/url/store/mutation-types.ts index 7d47ee8ca..556addc9b 100644 --- a/core/modules/url/store/mutation-types.ts +++ b/core/modules/url/store/mutation-types.ts @@ -1,4 +1,3 @@ -export const REGISTER_MAPPING = 'URL/REGISTER_MAPPING' export const SET_CURRENT_ROUTE = 'URL/SET_CURRENT_ROUTE' export const SET_PREV_ROUTE = 'URL/SET_PREV_ROUTE' export const IS_BACK_ROUTE = 'URL/IS_BACK_ROUTE' diff --git a/core/modules/url/store/mutations.ts b/core/modules/url/store/mutations.ts index abbabf26d..dc0bbeab4 100644 --- a/core/modules/url/store/mutations.ts +++ b/core/modules/url/store/mutations.ts @@ -3,14 +3,11 @@ import * as types from './mutation-types' import omit from 'lodash-es/omit' export const mutations: MutationTree = { - [types.REGISTER_MAPPING] (state, payload) { - state.dispatcherMap = Object.assign({}, state.dispatcherMap, { [payload.url]: payload.routeData }) - }, [types.SET_CURRENT_ROUTE] (state, payload = {}) { - state.currentRoute = omit({...payload}, ['matched']) + state.currentRoute = omit({ ...payload }, ['matched']) }, [types.SET_PREV_ROUTE] (state, payload = {}) { - state.prevRoute = omit({...payload}, ['matched']) + state.prevRoute = omit({ ...payload }, ['matched']) }, [types.IS_BACK_ROUTE] (state, payload) { state.isBackRoute = payload diff --git a/core/modules/url/test/unit/store/actions.spec.ts b/core/modules/url/test/unit/store/actions.spec.ts index bdbd29b02..74115fd8f 100644 --- a/core/modules/url/test/unit/store/actions.spec.ts +++ b/core/modules/url/test/unit/store/actions.spec.ts @@ -1,17 +1,10 @@ -import * as types from '@vue-storefront/core/modules/url/store/mutation-types'; import { cacheStorage } from '@vue-storefront/core/modules/recently-viewed/index'; import { actions as urlActions } from '../../../store/actions'; -import { currentStoreView, localizedDispatcherRouteName } from '@vue-storefront/core/lib/multistore'; +import { currentStoreView, removeStoreCodeFromRoute, localizedDispatcherRouteName } from '@vue-storefront/core/lib/multistore'; import { normalizeUrlPath, parametrizeRouteData } from '../../../helpers'; - -const SearchQuery = { - applyFilter: jest.fn() -}; +import { transformProductUrl } from '@vue-storefront/core/modules/url/helpers/transformUrl'; jest.mock('@vue-storefront/core/store', () => ({ Module: jest.fn() })) -jest.mock('@vue-storefront/core/lib/search/searchQuery', () => () => - SearchQuery -); jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); jest.mock('@vue-storefront/core/modules/recently-viewed/index', () => ({ cacheStorage: { @@ -61,6 +54,11 @@ jest.mock('@vue-storefront/core/app', () => ({ addRoutes: jest.fn() } })); +jest.mock('@vue-storefront/core/modules/url/helpers/transformUrl', () => ({ + transformProductUrl: jest.fn(), + transformCategoryUrl: jest.fn(), + transformCmsPageUrl: jest.fn() +})); let url: string; let routeData: any; @@ -76,17 +74,15 @@ describe('Url actions', () => { describe('registerMapping action', () => { it('should call register mapping mutation', async () => { const contextMock = { - commit: jest.fn() + state: { + dispatcherMap: {} + } }; const result = await (urlActions as any).registerMapping(contextMock, { url, routeData }); - expect(contextMock.commit).toHaveBeenCalledWith(types.REGISTER_MAPPING, { - url, - routeData - }); expect(result).toEqual(routeData); }); }); @@ -183,7 +179,7 @@ describe('Url actions', () => { }); }); - describe('mappingFallBack action', () => { + describe('mapFallbackUrl action', () => { beforeEach(() => { (currentStoreView as jest.Mock).mockImplementation(() => ({ storeCode: '', @@ -191,54 +187,70 @@ describe('Url actions', () => { })); }); - it('should return the proper URL from API for products', async () => { - url = '/men/bottoms-men/shorts-men/shorts-19/troy-yoga-short-994.html'; - (localizedDispatcherRouteName as jest.Mock).mockImplementation(() => url); + it('should trigger fetch from url module', async () => { + url = 'men/bottoms-men/shorts-men/shorts-19/troy-yoga-short-994.html'; + (removeStoreCodeFromRoute as jest.Mock).mockImplementation(() => url); const contextMock = { dispatch: jest.fn() }; - const params = { - slug: 'slug', - sku: 'parentsku2', - childSku: 'childSku' + + const wrapper = (actions: any) => actions.mapFallbackUrl(contextMock, { url }); + + await wrapper(urlActions); + + expect(contextMock.dispatch).toBeCalledWith('getFallbackByUrl', { url }) + }); + + it('should return page-not-found if missing record from ES', async () => { + url = 'men/bottoms-men/shorts-men/shorts-19/troy-yoga-short-994.html'; + (removeStoreCodeFromRoute as jest.Mock).mockImplementation(() => url); + + const contextMock = { + dispatch: jest.fn() }; - contextMock.dispatch.mockImplementation(() => Promise.resolve({ items: [ { name: 'name1', qty: 2, slug: 'slug1', sku: 'parentsku2' } ] })) + const wrapper = (actions: any) => actions.mapFallbackUrl(contextMock, { url }); - const result = await (urlActions as any).mappingFallback(contextMock, { url, params }); + const result = await wrapper(urlActions); expect(result).toEqual({ - name: '/men/bottoms-men/shorts-men/shorts-19/troy-yoga-short-994.html', + name: 'page-not-found', params: { - slug: 'slug1', - parentSku: 'parentsku2', - childSku: 'childSku' + slug: 'page-not-found' } - }); + }) }); + }); - it('should return return the proper URL from API for category', async () => { - url = '/men/bottoms-men/shorts-men/shorts-19'; - (localizedDispatcherRouteName as jest.Mock).mockImplementation(() => url); - + describe('transformFallback action', () => { + it('should call transformation function based on _type', async () => { const contextMock = { dispatch: jest.fn() }; - const params = { - slug: 'shorts-19' + + const wrapper = (actions: any) => actions.transformFallback(contextMock, { _type: 'product' }); + + await wrapper(urlActions); + + expect(transformProductUrl).toBeCalled() + }); + + it('should return by default page-not-found', async () => { + const contextMock = { + dispatch: jest.fn() }; - contextMock.dispatch.mockImplementation(() => Promise.resolve({ slug: 'shorts-19' })) + const wrapper = (actions: any) => actions.transformFallback(contextMock, { _type: 'xyz' }); - const result = await (urlActions as any).mappingFallback(contextMock, { url, params }); + const result = await wrapper(urlActions); expect(result).toEqual({ - name: '/men/bottoms-men/shorts-men/shorts-19', + name: 'page-not-found', params: { - slug: 'shorts-19' + slug: 'page-not-found' } - }); - }) + }) + }); }); }); diff --git a/core/modules/url/test/unit/store/mutations.spec.ts b/core/modules/url/test/unit/store/mutations.spec.ts deleted file mode 100644 index e5f0ee1a3..000000000 --- a/core/modules/url/test/unit/store/mutations.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import * as types from '../../../store/mutation-types' -import { mutations as urlMutations } from '../../../store/mutations' - -describe('url mutations', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - describe('REGISTER_MAPPING', () => { - it('should register mapping', () => { - const stateMock = { - dispatcherMap: {} - } - const payloadData = { - url: 'https://www.example.com', - routeData: { name: 'example' } - } - const expectedState = { - dispatcherMap: { - 'https://www.example.com': { name: 'example' } - } - } - const wrapper = (mutations: any) => mutations[types.REGISTER_MAPPING](stateMock, payloadData) - - wrapper(urlMutations) - - expect(stateMock).toEqual(expectedState) - }) - }) -}) diff --git a/core/modules/user/index.ts b/core/modules/user/index.ts index 7d864b473..7f88614cc 100644 --- a/core/modules/user/index.ts +++ b/core/modules/user/index.ts @@ -2,6 +2,7 @@ import { userStore } from './store' import { StorefrontModule } from '@vue-storefront/core/lib/modules' import { StorageManager } from '@vue-storefront/core/lib/storage-manager' import { isServer } from '@vue-storefront/core/helpers' +import { Logger } from '@vue-storefront/core/lib/logger' import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' import * as types from './store/mutation-types' @@ -36,7 +37,7 @@ export const UserModule: StorefrontModule = async function ({ store }) { type.endsWith(types.USER_INFO_LOADED) ) { StorageManager.get('user').setItem('current-user', state.user.current).catch((reason) => { - console.error(reason) // it doesn't work on SSR + Logger.error(reason)() // it doesn't work on SSR }) // populate cache } @@ -44,7 +45,7 @@ export const UserModule: StorefrontModule = async function ({ store }) { type.endsWith(types.USER_ORDERS_HISTORY_LOADED) ) { StorageManager.get('user').setItem('orders-history', state.user.orders_history).catch((reason) => { - console.error(reason) // it doesn't work on SSR + Logger.error(reason)() // it doesn't work on SSR }) // populate cache } @@ -52,11 +53,11 @@ export const UserModule: StorefrontModule = async function ({ store }) { type.endsWith(types.USER_TOKEN_CHANGED) ) { StorageManager.get('user').setItem('current-token', state.user.token).catch((reason) => { - console.error(reason) // it doesn't work on SSR + Logger.error(reason)() // it doesn't work on SSR }) // populate cache if (state.user.refreshToken) { StorageManager.get('user').setItem('current-refresh-token', state.user.refreshToken).catch((reason) => { - console.error(reason) // it doesn't work on SSR + Logger.error(reason)() // it doesn't work on SSR }) // populate cache } } diff --git a/core/modules/user/store/actions.ts b/core/modules/user/store/actions.ts index 16561ec8a..6ddad8891 100644 --- a/core/modules/user/store/actions.ts +++ b/core/modules/user/store/actions.ts @@ -46,6 +46,13 @@ const actions: ActionTree = { resetPassword (context, { email }) { return UserService.resetPassword(email) }, + /** + * Create new password for provided email with resetToken + * We could receive resetToken by running user.resetPassword action + */ + createPassword (context, { email, newPassword, resetToken }) { + return UserService.createPassword(email, newPassword, resetToken) + }, /** * Login user and return user profile and current token */ diff --git a/core/package.json b/core/package.json index 5f5cf8f6d..35ec249b4 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@vue-storefront/core", - "version": "1.11.4", + "version": "1.12.0", "description": "Vue Storefront Core", "license": "MIT", "main": "app.js", diff --git a/core/pages/Category.js b/core/pages/Category.js index 882886461..b89817ca4 100644 --- a/core/pages/Category.js +++ b/core/pages/Category.js @@ -1,7 +1,6 @@ import Vue from 'vue' import toString from 'lodash-es/toString' import config from 'config' - import i18n from '@vue-storefront/i18n' import store from '@vue-storefront/core/store' import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' @@ -84,10 +83,6 @@ export default { const defaultFilters = config.products.defaultFilters store.dispatch('category/resetFilters') EventBus.$emit('filter-reset') - await store.dispatch('attribute/list', { // load filter attributes for this specific category - filterValues: defaultFilters, // TODO: assign specific filters/ attribute codes dynamicaly to specific categories - includeFields: config.entities.optimize && isServer ? config.entities.attribute.includeFields : null - }) const parentCategory = await store.dispatch('category/single', { key: config.products.useMagentoUrlKeys ? 'url_key' : 'slug', value: route.params.slug }) let query = store.getters['category/getCurrentCategoryProductQuery'] if (!query.searchProductQuery) { @@ -277,7 +272,7 @@ export default { metaInfo () { const storeView = currentStoreView() return { - link: [ + /* link: [ { rel: 'amphtml', href: this.$router.resolve(localizedRoute({ name: 'category-amp', @@ -286,7 +281,7 @@ export default { } }, storeView.storeCode)).href } - ], + ], */ title: htmlDecode(this.category.meta_title || this.categoryName), meta: this.category.meta_description ? [{ vmid: 'description', name: 'description', content: htmlDecode(this.category.meta_description) }] : [] } diff --git a/core/pages/Checkout.js b/core/pages/Checkout.js index e11133914..0dc2c893f 100644 --- a/core/pages/Checkout.js +++ b/core/pages/Checkout.js @@ -5,7 +5,7 @@ import VueOfflineMixin from 'vue-offline/mixin' import { mapGetters } from 'vuex' import { StorageManager } from '@vue-storefront/core/lib/storage-manager' import Composite from '@vue-storefront/core/mixins/composite' -import { currentStoreView } from '@vue-storefront/core/lib/multistore' +import { currentStoreView, localizedRoute } from '@vue-storefront/core/lib/multistore' import { isServer } from '@vue-storefront/core/helpers' import { Logger } from '@vue-storefront/core/lib/logger' @@ -343,7 +343,7 @@ export default { asyncData ({ store, route, context }) { // this is for SSR purposes to prefetch data return new Promise((resolve, reject) => { if (context) context.output.cacheTags.add(`checkout`) - if (context) context.server.response.redirect('/') + if (context) context.server.response.redirect(localizedRoute('/')) resolve() }) } diff --git a/core/pages/CmsPage.js b/core/pages/CmsPage.js index e505cefcf..058794da0 100644 --- a/core/pages/CmsPage.js +++ b/core/pages/CmsPage.js @@ -7,7 +7,13 @@ export default { mixins: [Composite], computed: { pageTitle () { - return this.$store.state.cmsPage.current ? this.$store.state.cmsPage.current.title : '' + return this.$store.state.cmsPage.current ? this.$store.state.cmsPage.current.meta_title || this.$store.state.cmsPage.current.title : '' + }, + pageDescription () { + return this.$store.state.cmsPage.current ? this.$store.state.cmsPage.current.meta_description : '' + }, + pageKeywords () { + return this.$store.state.cmsPage.current ? this.$store.state.cmsPage.current.meta_keywords : '' } }, watch: { @@ -39,7 +45,10 @@ export default { metaInfo () { return { title: htmlDecode(this.pageTitle || this.$route.meta.title), - meta: this.$route.meta.description ? [{ vmid: 'description', name: 'description', content: htmlDecode(this.$route.meta.description) }] : [] + meta: [ + { vmid: 'description', name: 'description', content: htmlDecode(this.pageDescription || this.$route.meta.description) }, + { vmid: 'keywords', name: 'keywords', content: htmlDecode(this.pageKeywords) } + ] } } } diff --git a/core/pages/PageNotFound.js b/core/pages/PageNotFound.js index 808df37a5..a55fda111 100644 --- a/core/pages/PageNotFound.js +++ b/core/pages/PageNotFound.js @@ -14,13 +14,17 @@ export default { context.server.response.statusCode = 404 } let ourBestsellersQuery = prepareQuery({ queryConfig: 'bestSellers' }) - const response = await store.dispatch('product/list', { + const { items } = await store.dispatch('product/findProducts', { query: ourBestsellersQuery, size: 8, - sort: 'created_at:desc' + sort: 'created_at:desc', + options: { + populateRequestCacheTags: false, + prefetchGroupProducts: false + } }) - if (response) { - store.state.homepage.bestsellers = response.items + if (items.length) { + store.state.homepage.bestsellers = items } }, metaInfo () { diff --git a/core/pages/Product.js b/core/pages/Product.js index 439cd9d88..8f885521e 100644 --- a/core/pages/Product.js +++ b/core/pages/Product.js @@ -227,7 +227,7 @@ export default { metaInfo () { const storeView = currentStoreView() return { - link: [ + /* link: [ { rel: 'amphtml', href: this.$router.resolve(localizedRoute({ name: this.product.type_id + '-product-amp', @@ -238,7 +238,7 @@ export default { } }, storeView.storeCode)).href } - ], + ], */ title: htmlDecode(this.product.meta_title || this.productName), meta: this.product.meta_description ? [{ vmid: 'description', name: 'description', content: htmlDecode(this.product.meta_description) }] : [] } diff --git a/core/scripts/generate-files.ts b/core/scripts/generate-files.ts new file mode 100644 index 000000000..284201964 --- /dev/null +++ b/core/scripts/generate-files.ts @@ -0,0 +1,28 @@ +import fs from 'fs'; +import path from 'path'; +import config from 'config'; + +fs.writeFileSync( + path.resolve(__dirname, '../build/config.json'), + JSON.stringify(config) +) + +const csvDirectories = [ + path.resolve(__dirname, '../../node_modules/@vue-storefront/i18n/resource/i18n/') +] + +const moduleRoot = path.resolve(__dirname, '../../src/modules') +fs.readdirSync(moduleRoot).forEach(directory => { + const dirName = moduleRoot + '/' + directory + '/resource/i18n' + + if (fs.existsSync(dirName)) { + csvDirectories.push(dirName); + } +}); + +const themeRoot = require('../build/theme-path'); +const themeResources = themeRoot + '/resource' +csvDirectories.push(path.resolve(__dirname, themeResources + '/i18n/')); + +const translationPreprocessor = require('@vue-storefront/i18n/scripts/translation.preprocessor.js') +translationPreprocessor(csvDirectories, config) diff --git a/core/scripts/installer.js b/core/scripts/installer.js index 21e6d35ad..e80aeeeb9 100644 --- a/core/scripts/installer.js +++ b/core/scripts/installer.js @@ -428,9 +428,11 @@ class Storefront extends Abstract { config.elasticsearch.host = `${backendPath}/api/catalog` config.orders.endpoint = `${backendPath}/api/order` config.products.endpoint = `${backendPath}/api/product` + config.users.loginAfterCreatePassword = true config.users.endpoint = `${backendPath}/api/user` config.users.history_endpoint = `${backendPath}/api/user/order-history?token={{token}}&pageSize={{pageSize}}¤tPage={{currentPage}}` config.users.resetPassword_endpoint = `${backendPath}/api/user/reset-password` + config.users.createPassword_endpoint = `${backendPath}/api/user/create-password` config.users.changePassword_endpoint = `${backendPath}/api/user/change-password?token={{token}}` config.users.login_endpoint = `${backendPath}/api/user/login` config.users.create_endpoint = `${backendPath}/api/user/create` @@ -457,6 +459,55 @@ class Storefront extends Abstract { config.cms.endpoint = `${backendPath}/api/ext/cms-data/cms{{type}}/{{cmsId}}` config.cms.endpointIdentifier = `${backendPath}/api/ext/cms-data/cms{{type}}Identifier/{{cmsIdentifier}}/storeId/{{storeId}}` + if (this.answers.ssr_endpoints) { + if (Abstract.wasLocalBackendInstalled) { + graphQlHost = 'localhost' + backendPath = 'http://localhost:8080' + } else { + backendPath = STOREFRONT_REMOTE_BACKEND_URL + graphQlHost = backendPath.replace('https://', '').replace('http://', '') + } + + // Do we really need protocol_ssr in a different place than GraphQL? + config.server.protocol_ssr = 'http' + config.api.url_ssr = backendPath + config.graphql.host_ssr = graphQlHost + config.graphql.port_ssr = graphQlPort + config.elasticsearch.host_ssr = `${backendPath}/api/catalog` + config.orders.endpoint_ssr = `${backendPath}/api/order` + config.products.endpoint_ssr = `${backendPath}/api/product` + config.users.endpoint_ssr = `${backendPath}/api/user` + config.users.history_endpoint_ssr = `${backendPath}/api/user/order-history?token={{token}}` + config.users.resetPassword_endpoint_ssr = `${backendPath}/api/user/reset-password` + config.users.changePassword_endpoint_ssr = `${backendPath}/api/user/change-password?token={{token}}` + config.users.login_endpoint_ssr = `${backendPath}/api/user/login` + config.users.create_endpoint_ssr = `${backendPath}/api/user/create` + config.users.me_endpoint_ssr = `${backendPath}/api/user/me?token={{token}}` + config.users.refresh_endpoint_ssr = `${backendPath}/api/user/refresh` + config.stock.endpoint_ssr = `${backendPath}/api/stock` + config.cart.create_endpoint_ssr = `${backendPath}/api/cart/create?token={{token}}` + config.cart.updateitem_endpoint_ssr = `${backendPath}/api/cart/update?token={{token}}&cartId={{cartId}}` + config.cart.deleteitem_endpoint_ssr = `${backendPath}/api/cart/delete?token={{token}}&cartId={{cartId}}` + config.cart.pull_endpoint_ssr = `${backendPath}/api/cart/pull?token={{token}}&cartId={{cartId}}` + config.cart.totals_endpoint_ssr = `${backendPath}/api/cart/totals?token={{token}}&cartId={{cartId}}` + config.cart.paymentmethods_endpoint_ssr = `${backendPath}/api/cart/payment-methods?token={{token}}&cartId={{cartId}}` + config.cart.shippingmethods_endpoint_ssr = `${backendPath}/api/cart/shipping-methods?token={{token}}&cartId={{cartId}}` + config.cart.shippinginfo_endpoint_ssr = `${backendPath}/api/cart/shipping-information?token={{token}}&cartId={{cartId}}` + config.cart.collecttotals_endpoint_ssr = `${backendPath}/api/cart/collect-totals?token={{token}}&cartId={{cartId}}` + config.cart.deletecoupon_endpoint_ssr = `${backendPath}/api/cart/delete-coupon?token={{token}}&cartId={{cartId}}` + config.cart.applycoupon_endpoint_ssr = `${backendPath}/api/cart/apply-coupon?token={{token}}&cartId={{cartId}}&coupon={{coupon}}` + config.reviews.create_endpoint_ssr = `${backendPath}/api/review/create?token={{token}}` + + // Probably pointless (only CS) + // config.newsletter.endpoint_ssr = `${backendPath}/api/ext/mailchimp-subscribe/subscribe` + config.mailer.endpoint.send_ssr = `${backendPath}/api/ext/mail-service/send-email` + config.mailer.endpoint.token_ssr = `${backendPath}/api/ext/mail-service/get-token` + // Probably pointless (only CS) + // config.images.baseUrl_ssr = this.answers.images_endpoint + config.cms.endpoint_ssr = `${backendPath}/api/ext/cms-data/cms{{type}}/{{cmsId}}` + config.cms.endpointIdentifier_ssr = `${backendPath}/api/ext/cms-data/cms{{type}}Identifier/{{cmsIdentifier}}/storeId/{{storeId}}` + } + config.install = { is_local_backend: Abstract.wasLocalBackendInstalled, backend_dir: this.answers.backend_dir || false @@ -806,6 +857,12 @@ let questions = [ when: function (answers) { return answers.m2_api_oauth2 === true } + }, + { + type: 'confirm', + name: 'ssr_endpoints', + message: `Would You like to create fields for SSR endpoints?`, + default: false } ] diff --git a/core/scripts/resolvers/resolveGraphQL.js b/core/scripts/resolvers/resolveGraphQL.js index a1b651c60..539e0609d 100644 --- a/core/scripts/resolvers/resolveGraphQL.js +++ b/core/scripts/resolvers/resolveGraphQL.js @@ -2,6 +2,7 @@ import { server, graphql } from 'config' import Vue from 'vue' import { Logger } from '@vue-storefront/core/lib/logger' import { once } from '@vue-storefront/core/helpers' +import { isServer } from '@vue-storefront/core/helpers'; export const getApolloProvider = async () => { if (server.api === 'graphql') { @@ -15,8 +16,22 @@ export const getApolloProvider = async () => { const HttpLinkModule = await import(/* webpackChunkName: "vsf-graphql" */ 'apollo-link-http') const HttpLink = HttpLinkModule.HttpLink + let uri + if (isServer && (graphql.host_ssr || graphql.port_ssr)) { + const host = graphql.host_ssr || graphql.host + const port = graphql.port_ssr || graphql.port + + uri = host.indexOf('://') >= 0 + ? host + : (server.protocol + '://' + host + ':' + port + '/graphql') + } else { + uri = graphql.host.indexOf('://') >= 0 + ? graphql.host + : (server.protocol + '://' + graphql.host + ':' + graphql.port + '/graphql') + } + const httpLink = new HttpLink({ - uri: graphql.host.indexOf('://') >= 0 ? graphql.host : (server.protocol + '://' + graphql.host + ':' + graphql.port + '/graphql') + uri }) const ApolloClientModule = await import(/* webpackChunkName: "vsf-graphql" */ 'apollo-client') @@ -51,7 +66,7 @@ export const getApolloProvider = async () => { }) return apolloProvider - } + } else return null } export default { diff --git a/core/scripts/server.ts b/core/scripts/server.ts index c8dcf54d4..ddf44f667 100755 --- a/core/scripts/server.ts +++ b/core/scripts/server.ts @@ -1,7 +1,9 @@ import { serverHooksExecutors } from '@vue-storefront/core/server/hooks' + let config = require('config') const path = require('path') const glob = require('glob') +const fs = require('fs') const rootPath = require('app-root-path').path const resolve = file => path.resolve(rootPath, file) const serverExtensions = glob.sync('src/modules/*/server.{ts,js}') @@ -91,13 +93,13 @@ function invalidateCache (req, res) { } }) - serverHooksExecutors.afterCacheInvalidated() - Promise.all(subPromises).then(r => { apiStatus(res, `Tags invalidated successfully [${req.query.tag}]`, 200) }).catch(error => { apiStatus(res, error, 500) console.error(error) + }).finally(() => { + serverHooksExecutors.afterCacheInvalidated({ tags, req }) }) if (config.server.invalidateCacheForwarding) { // forward invalidate request to the next server in the chain @@ -146,6 +148,12 @@ app.use('/service-worker.js', serve('dist/service-worker.js', false, { app.post('/invalidate', invalidateCache) app.get('/invalidate', invalidateCache) +function cacheVersion (req, res) { + res.send(fs.readFileSync(resolve('core/build/cache-version.json'))) +} + +app.get('/cache-version.json', cacheVersion) + app.get('*', (req, res, next) => { if (NOT_ALLOWED_SSR_EXTENSIONS_REGEX.test(req.url)) { apiStatus(res, 'Vue Storefront: Resource is not found', 404) @@ -165,10 +173,14 @@ app.get('*', (req, res, next) => { } else { console.error(`Error during render : ${req.url}`) console.error(err) + serverHooksExecutors.ssrException({ err, req, isProd }) return res.redirect('/error') } } + const site = req.headers['x-vs-store-code'] || 'main' + const cacheKey = `page:${site}:${req.url}` + const dynamicRequestHandler = renderer => { if (!renderer) { res.setHeader('Content-Type', 'text/html') @@ -205,7 +217,7 @@ app.get('*', (req, res, next) => { output = ssr.applyAdvancedOutputProcessing(context, output, templatesCache, isProd); if (config.server.useOutputCache && cache) { cache.set( - 'page:' + req.url, + cacheKey, { headers: res.getHeaders(), body: output, httpCode: res.statusCode }, tagsArray ).catch(errorHandler) @@ -238,7 +250,7 @@ app.get('*', (req, res, next) => { const dynamicCacheHandler = () => { if (config.server.useOutputCache && cache) { cache.get( - 'page:' + req.url + cacheKey ).then(output => { if (output !== null) { if (output.headers) { @@ -309,20 +321,21 @@ app.get('*', (req, res, next) => { let port = process.env.PORT || config.server.port const host = process.env.HOST || config.server.host const start = () => { - app.listen(port, host) - .on('listening', () => { - console.log(`\n\n----------------------------------------------------------`) - console.log('| |') - console.log(`| Vue Storefront Server started at http://${host}:${port} |`) - console.log('| |') - console.log(`----------------------------------------------------------\n\n`) - }) - .on('error', (e) => { - if (e.code === 'EADDRINUSE') { - port = parseInt(port) + 1 - console.log(`The port is already in use, trying ${port}`) - start() - } - }) + const server = app.listen(port, host) + server.on('listening', () => { + console.log(`\n\n----------------------------------------------------------`) + console.log('| |') + console.log(`| Vue Storefront Server started at http://${host}:${port} |`) + console.log('| |') + console.log(`----------------------------------------------------------\n\n`) + + serverHooksExecutors.httpServerIsReady({ server, config: config.server, isProd }) + }).on('error', (e) => { + if (e.code === 'EADDRINUSE') { + port = parseInt(port) + 1 + console.log(`The port is already in use, trying ${port}`) + start() + } + }) } start() diff --git a/core/scripts/utils/catalog-client.ts b/core/scripts/utils/catalog-client.ts index 66a92bc62..8d2a1f802 100644 --- a/core/scripts/utils/catalog-client.ts +++ b/core/scripts/utils/catalog-client.ts @@ -1,14 +1,15 @@ import queryString from 'query-string' import fetch from 'isomorphic-fetch' +import getApiEndpointUrl from '@vue-storefront/core/helpers/getApiEndpointUrl'; export const processURLAddress = (url: string = '', config: any) => { - if (url.startsWith('/')) return `${config.api.url}${url}` + if (url.startsWith('/')) return `${getApiEndpointUrl(config.api, 'url')}${url}` return url } export async function search (request, storeView, config) { const elasticsearchQueryBody = request.searchQuery if (!request.index) request.index = storeView.elasticsearch.index - let url = processURLAddress(storeView.elasticsearch.host, config) + let url = processURLAddress(getApiEndpointUrl(storeView.elasticsearch, 'host'), config) const httpQuery: { size: number, diff --git a/core/scripts/utils/ssr-renderer.js b/core/scripts/utils/ssr-renderer.ts similarity index 88% rename from core/scripts/utils/ssr-renderer.js rename to core/scripts/utils/ssr-renderer.ts index 8694fe9d7..c64f177a9 100644 --- a/core/scripts/utils/ssr-renderer.js +++ b/core/scripts/utils/ssr-renderer.ts @@ -1,3 +1,4 @@ +import { Context } from './types'; const fs = require('fs') const path = require('path') const compile = require('lodash.template') @@ -9,7 +10,18 @@ const get = require('lodash/get') const config = require('config') const minify = require('html-minifier').minify -function createRenderer (bundle, clientManifest, template) { +function createRenderer (bundle, clientManifest, template?) { + let shouldPreload = () => {} + let shouldPrefetch = () => {} + try { + const scripts = require('../../modules/initial-resources/serverResourcesFilter') + shouldPreload = scripts.shouldPreload + shouldPrefetch = scripts.shouldPrefetch + } catch (err) { + if (config.initialResources) { + console.error(err) + } + } const LRU = require('lru-cache') // https://github.com/vuejs/vue/blob/dev/packages/vue-server-renderer/README.md#why-use-bundlerenderer return require('vue-server-renderer').createBundleRenderer(bundle, { @@ -18,7 +30,9 @@ function createRenderer (bundle, clientManifest, template) { cache: new LRU({ max: 1000, maxAge: 1000 * 60 * 15 - }) + }), + shouldPreload, + shouldPrefetch }) } @@ -92,7 +106,7 @@ function initTemplatesCache (config, compileOptions) { return templatesCache } -function initSSRRequestContext (app, req, res, config) { +function initSSRRequestContext (app, req, res, config): Context { return { url: decodeURI(req.url), output: { @@ -122,11 +136,11 @@ function clearContext (context) { delete context['meta'] } -module.exports = { +export { createRenderer, initTemplatesCache, initSSRRequestContext, applyAdvancedOutputProcessing, - compileTemplate: compile, + compile as compileTemplate, clearContext } diff --git a/core/scripts/utils/types/index.ts b/core/scripts/utils/types/index.ts new file mode 100644 index 000000000..373620cac --- /dev/null +++ b/core/scripts/utils/types/index.ts @@ -0,0 +1,23 @@ +import { Express } from 'express' + +export interface Context { + url: string, + output: { + prepend: (context: any) => string, + append: (context: any) => string, + filter: (output: T, context: any) => T, + appendHead: (context: any) => string, + template: string, + cacheTags: Set + }, + server: { + app: Express, + response: Express.Response, + request: Express.Request + }, + meta: any|null, + vs: { + config: Record, + storeCode: string + } +} diff --git a/core/server/hooks.ts b/core/server/hooks.ts index 630871343..0fa31db24 100644 --- a/core/server/hooks.ts +++ b/core/server/hooks.ts @@ -1,5 +1,6 @@ import { createListenerHook, createMutatorHook } from '@vue-storefront/core/lib/hooks' -import { Express, Request } from 'express'; +import { Express, Request } from 'express' +import http from 'http' // To add like tracing which needs to be done as early as possible @@ -13,6 +14,11 @@ interface BeforeCacheInvalidatedParamter { req: Request } +interface AfterCacheInvalidatedParamter { + tags: string[], + req: Request +} + const { hook: beforeCacheInvalidatedHook, executor: beforeCacheInvalidatedExecutor @@ -21,7 +27,7 @@ const { const { hook: afterCacheInvalidatedHook, executor: afterCacheInvalidatedExecutor -} = createListenerHook() +} = createListenerHook() // beforeStartApp interface Extend { @@ -29,11 +35,34 @@ interface Extend { config: any, isProd: boolean } + const { hook: afterApplicationInitializedHook, executor: afterApplicationInitializedExecutor } = createListenerHook() +interface Server { + server: http.Server, + config: any, + isProd: boolean +} + +const { + hook: httpServerIsReadyHook, + executor: httpServerIsReadyExecutor +} = createListenerHook() + +interface Exception { + err: Exception, + req: Request, + isProd: boolean +} + +const { + hook: ssrExceptionHook, + executor: ssrExceptionExecutor +} = createListenerHook() + const { hook: beforeOutputRenderedResponseHook, executor: beforeOutputRenderedResponseExecutor @@ -48,6 +77,8 @@ const { const serverHooksExecutors = { afterProcessStarted: afterProcessStartedExecutor, afterApplicationInitialized: afterApplicationInitializedExecutor, + httpServerIsReady: httpServerIsReadyExecutor, + ssrException: ssrExceptionExecutor, beforeOutputRenderedResponse: beforeOutputRenderedResponseExecutor, afterOutputRenderedResponse: afterOutputRenderedResponseExecutor, beforeCacheInvalidated: beforeCacheInvalidatedExecutor, @@ -63,6 +94,8 @@ const serverHooks = { * */ afterApplicationInitialized: afterApplicationInitializedHook, + httpServerIsReady: httpServerIsReadyHook, + ssrException: ssrExceptionHook, beforeOutputRenderedResponse: beforeOutputRenderedResponseHook, afterOutputRenderedResponse: afterOutputRenderedResponseHook, beforeCacheInvalidated: beforeCacheInvalidatedHook, diff --git a/core/test/unit/helpers/buildFilterProductsQuery.spec.ts b/core/test/unit/helpers/buildFilterProductsQuery.spec.ts index 66938381f..77d35cbe9 100644 --- a/core/test/unit/helpers/buildFilterProductsQuery.spec.ts +++ b/core/test/unit/helpers/buildFilterProductsQuery.spec.ts @@ -62,7 +62,7 @@ describe('buildFilterProductsQuery method', () => { it('should build default query', () => { const result = buildFilterProductsQuery(currentCategory) - const categoryFilter = result._appliedFilters.find(filter => filter.attribute === 'category_ids') + const categoryFilter = result.getAppliedFilters().find(filter => filter.attribute === 'category_ids') expect(categoryFilter).toBeDefined() expect(categoryFilter.value.in).toEqual([20, 21, 23, 24, 25, 26, 22, 27, 28]) }); @@ -85,7 +85,7 @@ describe('buildFilterProductsQuery method', () => { ] } const result = buildFilterProductsQuery(currentCategory, filters) - const categoryFilter = result._appliedFilters.find(filter => filter.attribute === 'color') + const categoryFilter = result.getAppliedFilters().find(filter => filter.attribute === 'color') expect(categoryFilter).toBeDefined() expect(categoryFilter.value.in).toEqual(['49', '50']) }); @@ -102,7 +102,7 @@ describe('buildFilterProductsQuery method', () => { } } const result = buildFilterProductsQuery(currentCategory, filters) - const categoryFilter = result._appliedFilters.find(filter => filter.attribute === 'price') + const categoryFilter = result.getAppliedFilters().find(filter => filter.attribute === 'price') expect(categoryFilter).toBeDefined() expect(categoryFilter.value.lte).toEqual(50) }); @@ -119,7 +119,7 @@ describe('buildFilterProductsQuery method', () => { } } const result = buildFilterProductsQuery(currentCategory, filters) - const categoryFilter = result._appliedFilters.find(filter => filter.attribute === 'price') + const categoryFilter = result.getAppliedFilters().find(filter => filter.attribute === 'price') expect(categoryFilter).toBeDefined() expect(categoryFilter.value.gte).toEqual(50) expect(categoryFilter.value.lte).toEqual(100) @@ -137,7 +137,7 @@ describe('buildFilterProductsQuery method', () => { }] } const result = buildFilterProductsQuery(currentCategory, filters) - const categoryFilter = result._appliedFilters.find(filter => filter.attribute === 'price') + const categoryFilter = result.getAppliedFilters().find(filter => filter.attribute === 'price') expect(categoryFilter).toBeDefined() expect(categoryFilter.value.lte).toEqual(50) }); @@ -168,10 +168,10 @@ describe('buildFilterProductsQuery method', () => { ] } const result = buildFilterProductsQuery(currentCategory, filters) - const colorFilter = result._appliedFilters.find(filter => filter.attribute === 'color') + const colorFilter = result.getAppliedFilters().find(filter => filter.attribute === 'color') expect(colorFilter).toBeDefined() expect(colorFilter.value.in).toEqual(['49', '50']) - const erinFilter = result._appliedFilters.find(filter => filter.attribute === 'erin_recommends') + const erinFilter = result.getAppliedFilters().find(filter => filter.attribute === 'erin_recommends') expect(erinFilter).toBeDefined() expect(erinFilter.value.in).toEqual(['1']) }); diff --git a/core/types/asyncData.d.ts b/core/types/asyncData.d.ts new file mode 100644 index 000000000..d4dc961ed --- /dev/null +++ b/core/types/asyncData.d.ts @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import { Route } from 'vue-router'; +import { Context } from '@vue-storefront/core/scripts/utils/types' + +interface AsyncDataParameter { + store: any, + route: Route, + context?: Context +} + +declare module 'vue/types/options' { + interface ComponentOptions { + asyncData?: ({ store, route, context }: AsyncDataParameter) => Promise + } +} diff --git a/core/types/search/HttpQuery.ts b/core/types/search/HttpQuery.ts index dd04705e1..bcbab4bd4 100644 --- a/core/types/search/HttpQuery.ts +++ b/core/types/search/HttpQuery.ts @@ -4,6 +4,8 @@ export default interface HttpQuery { from: number, sort: string, request?: string, + request_format?: string, + response_format?: string, _source_exclude?: string, _source_include?: string } diff --git a/core/types/search/SearchResponse.ts b/core/types/search/SearchResponse.ts index 4622d4580..49880d1c4 100644 --- a/core/types/search/SearchResponse.ts +++ b/core/types/search/SearchResponse.ts @@ -7,5 +7,6 @@ export interface SearchResponse { offline?: boolean, cache?: boolean, noresults?: boolean, - suggestions: any + suggestions: any, + attributeMetadata?: any } diff --git a/cypress.json b/cypress.json index 80eb57500..51f68f789 100644 --- a/cypress.json +++ b/cypress.json @@ -4,5 +4,7 @@ "fixturesFolder": "test/e2e/fixtures", "integrationFolder": "test/e2e/integration", "pluginsFile": "test/e2e/plugins/index.js", - "supportFile": "test/e2e/support/index.js" + "supportFile": "test/e2e/support/index.js", + "viewportWidth": 1280, + "viewportHeight": 1024 } diff --git a/docker/vue-storefront/vue-storefront.sh b/docker/vue-storefront/vue-storefront.sh index a6db7c867..920b5c52b 100755 --- a/docker/vue-storefront/vue-storefront.sh +++ b/docker/vue-storefront/vue-storefront.sh @@ -3,7 +3,7 @@ set -e yarn install || exit $? -yarn build:client && yarn build:server && yarn build:sw || exit $? +yarn generate-files && yarn build:client && yarn build:server && yarn build:sw || exit $? if [ "$VS_ENV" = 'dev' ]; then yarn dev diff --git a/docs/guide/basics/configuration.md b/docs/guide/basics/configuration.md index df9d852e4..1a471f358 100644 --- a/docs/guide/basics/configuration.md +++ b/docs/guide/basics/configuration.md @@ -467,6 +467,12 @@ This is related to `alwaysSyncPlatformPricesOver` and when it's set to true, the This is related to `alwaysSyncPlatformPricesOver`. When true, Vue Storefront will wait for dynamic prices before rendering the page. Otherwise, the product and category pages will be rendered using the default (Elasticsearch-based) prices and then asynchronously override them with current ones. +```json + "alwaysSyncPricesClientSide": false, +``` + +This is related to `alwaysSyncPlatformPricesOver`. When true, Vue Storefront will force a refresh of the prices on the client side, including the token from the current logged in user, so customer specific pricing can be applied. + ```json "endpoint": "http://localhost:8080/api/product", diff --git a/docs/guide/cookbook/module.md b/docs/guide/cookbook/module.md index 3ec015afe..45f50decd 100644 --- a/docs/guide/cookbook/module.md +++ b/docs/guide/cookbook/module.md @@ -678,10 +678,98 @@ It's hands down no-brainer to bootstrap a module _manually_ because the skeleton ### 2. Recipe ### 3. Peep into the kitchen (what happens internally) ### 4. Chef's secret (protip) -
-
-## 6. Anti-patterns & Common pitfalls +## 6. Extend Elasticsearch request body using `storefront-query-builder` + +If you're using the new [`storefront-query-builder`](https://github.com/DivanteLtd/storefront-query-builder) and the `api-search-query` search-adapter ([introduced with v1.1.12](/guide/upgrade-notes/#_1-11-1-12)) it is now possible to extend it by new filters, or even overwrite a existing filter, to customize your Elasticsearch request-bodies. + +So, this way you can add custom Elasticsearch queries to the query-chain and still use the notation of `SearchQuery` in the Vue Storefront. + +> **Note:** This will only work from `storefront-query-builder` version `1.0.0` and `vue-storefront` version `1.12.2`. + +### Usecases + +One usecases where this feature would come in handy is for example if you like to add complex queries on multiple points in your source code. Using the following technique you can just add a custom filter to your `SearchQuery` in a single line inside your VSF source-code using the `query.applyFilter(...)` method and then add the complex logic into your custom-filter inside the API. + +### Registering a new filter + +The `vue-storefront-api` will only try to load filters that are registered in the configs. The extension/module, that contains the filter, must be enabled and the new filter module-classes needs to be registered in its extension config inside the `catalogFilter` array. The filter files must be located inside `filter/catalog/` of your module folder. + +For example: If you have a module called `extend-catalog` with a filter called `StockFilter`, the file path to filter would be `src/api/extensions/extend-catalog/filter/catalog/StockFilter.ts` and the config would look like: +``` +{ + "registeredExtensions": [ "extend-catalog" ], + "extensions": { + "extend-catalog": { + "catalogFilter": [ "StockFilter" ] + } + } +} +``` + +### Filter module-class properties + +The filter can contain four different properties. Followed a short explaination, what they are doing. + +* `check` – This method checks the condition that be must matched to execute the filter. The first valid filter is executed – all afterwards are ignored. +* `priority` – This is the priority in which the filters are going to be called. The sort is lower to higher. +* `mutator` – The mutator method is in charge of prehandling the filter value, to e.g. set defaults or check and change the type. +* `filter` – This method contains the query logic we wan't to add and mutates the `bodybuilder` query-chain. + +### Example + +Lets assume we like to add a possibility to add a default set of product-attribute filters we can apply to each `SearchQuery` without repeating ourselfs in source-code. So, for example, it should filter for two `color`'s and a specific `cut` to supply a filter for spring-coloured short's we implement at several places in our VSF. + +#### Changes in `vue-storefront` repository + +The query in the VSF code would look like this (that's it on the VSF side): +```js +import { SearchQuery } from 'storefront-query-builder' +import { quickSearchByQuery } from '@vue-storefront/core/lib/search' + +//... + +const query = new SearchQuery() +query.applyFilter({ key: 'spring-shorts', value: 'male', scope: 'default' }) +const products = await dispatch('product/list', { query, size: 5 }) +``` + +#### Changes in `vue-storefront-api` repository + +In the `vue-storefront-api` we are going to add the real filter/query magic. +There is already an example module called `example-custom-filter` which we are going to use for our filter. + +As you look inside its module folder `src/api/extensions/example-custom-filter/`, you will find a child folder `filter/catalog/` with all existing custom filters for this module. Inside this folder we are going to duplicate the existing `SampleFilter.ts` into another one called `SpringShorts.ts` – this is our new custom filter module-class. + +This file needs to be registered in the config JSON to let the API know that there is a new custom filter inside our extension. +Therefore you open your `default.json` or specific config JSON file and add our new filename `SpringShorts` to the config node `extensions.example-custom-filter.catalogFilter` array. + +Our `SpringShorts.ts` contains an object that contains [four properties](#filter-module-class-properties): `priority`, `check`, `filter`, `mutator`. We don't need a `mutator` nor `priority`, so we can remove these lines. `check` and `filter` needs to be changed to fulfill our needs. So, this is how our filter finally looks like: + +```js +import { FilterInterface } from 'storefront-query-builder' + +const filter: FilterInterface = { + check: ({ attribute }) => attribute === 'spring-shorts', + filter ({ value, attribute, operator, queryChain }) { + return queryChain + .filter('terms', 'pants', [ 'shorts' ]) + .filter('terms', 'cut', [ 1, 2 ]) + .filter('terms', 'color', [ 3, 4 ]) + .filter('terms', 'gender', [ value ]) + } +} + +export default filter +``` + +Inside `check` we tell the filter to just be applied if the attribute is named exactly `spring-shorts`. + +Inside `filter` we extend the Elasticsearch query-chain by our desired filters, using the `bodybuilder` library syntax. + +That's it, now we are able to filter by a complex query in only one line inside VSF. + +## 7. Anti-patterns & Common pitfalls ### 1. Preparation ### 2. Recipe @@ -699,7 +787,7 @@ _[INSERT VIDEO HERE]_

-## 7. Building a module from A to Z in an iteration +## 8. Building a module from A to Z in an iteration ### 1. Preparation @@ -709,7 +797,7 @@ _[INSERT VIDEO HERE]_

-## 8. Deprecated legacy of Modules +## 9. Deprecated legacy of Modules In this recipe, we will take a review of how to deal with modules in an old fashioned way , just in case you really need it. ### 1. Preparation @@ -720,7 +808,7 @@ In this recipe, we will take a review of how to deal with modules in an old fash
-## 9. Converting old modules to new modules +## 10. Converting old modules to new modules There are useful modules out there already developed in the old way. ### 1. Preparation diff --git a/docs/guide/cookbook/setup.md b/docs/guide/cookbook/setup.md index 12b0eba88..b00c07244 100644 --- a/docs/guide/cookbook/setup.md +++ b/docs/guide/cookbook/setup.md @@ -5,7 +5,7 @@ } -In this chapter, we will cover : +In this chapter, we will cover : [[toc]] @@ -14,23 +14,23 @@ In this chapter, we will cover : Now you are definitely interested in **Vue Storefront**. That's why you are here. You've come across the line. You made a choice. You will have something in return, which is great. Be it developers, entrepreneurs or even marketing managers that they may want to try something new for better products in hopes of enhancing their clients or customers' experience. You chose the right path. We will explore anything you need to get you started at all with [**Vue Storefront** infrastructure](https://github.com/DivanteLtd). ## 1. Install with Docker -Docker has been arguably the most sought-after, brought to the market which took the community by storm ever since its introduction. Although it's yet controversial whether it's the best choice among its peers, I have never seen such an unanimous enthusiasm over one tech product throughout the whole developers community. +Docker has been arguably the most sought-after, brought to the market which took the community by storm ever since its introduction. Although it's yet controversial whether it's the best choice among its peers, I have never seen such an unanimous enthusiasm over one tech product throughout the whole developers community. -Then, why so? In modern computer engineering, products are so complex with an endless list of dependencies intertwined with each other. Building such dependencies in place for every occasion where it's required is one hell of a job, not to mention glitches from all the version variation. That's where Docker steps in to make you achieve **infrastructure automation**. This concept was conceived to help you focus on your business logic rather than having you stuck with hassles of lower level tinkering. +Then, why so? In modern computer engineering, products are so complex with an endless list of dependencies intertwined with each other. Building such dependencies in place for every occasion where it's required is one hell of a job, not to mention glitches from all the version variation. That's where Docker steps in to make you achieve **infrastructure automation**. This concept was conceived to help you focus on your business logic rather than having you stuck with hassles of lower level tinkering. Luckily, we already have been through all this for you, got our hands dirty. All you need is run a set of docker commands to get you up and running from scratch. Without further ado, let's get started! ### 1. Preparation -- You need [`docker`](https://www.digitalocean.com/community/tutorials/how-to-install-and-use-docker-on-ubuntu-18-04) and [`docker-compose`](https://www.digitalocean.com/community/tutorials/how-to-install-docker-compose-on-ubuntu-18-04) installed. +- You need [`docker`](https://www.digitalocean.com/community/tutorials/how-to-install-and-use-docker-on-ubuntu-18-04) and [`docker-compose`](https://www.digitalocean.com/community/tutorials/how-to-install-docker-compose-on-ubuntu-18-04) installed. - You need [`git`](https://www.digitalocean.com/community/tutorials/how-to-install-git-on-ubuntu-18-04) installed. :::tip NOTE We will walk you with docker on *Linux*. (Specifically *Ubuntu 18.04* if needed) -There is only one bias for Docker before using it; *Run it on Linux*. Docker is native Linux, was created using a Linux technology; LXC (linux container) in the first place. Even though there were many attempts made to make it available to other platforms as it does on Linux, and it has definitely been on a progress, however, using Docker on Linux is the solidest way to deal with the technology. +There is only one bias for Docker before using it; *Run it on Linux*. Docker is native Linux, was created using a Linux technology; LXC (linux container) in the first place. Even though there were many attempts made to make it available to other platforms as it does on Linux, and it has definitely been on a progress, however, using Docker on Linux is the solidest way to deal with the technology. -That being sad, there are tips for using other platforms for docker at [Chef's Secrets](#_4-chef-s-secret-protip) as well. +That being sad, there are tips for using other platforms for docker at [Chef's Secrets](#_4-chef-s-secret-protip) as well. ::: ### 2. Recipe @@ -44,19 +44,19 @@ cd vue-storefront-api ```bash cp config/default.json config/local.json ``` -Then edit `local.json` to your need. +Then edit `local.json` to your need. We will look into this in greater detail at [Chef's secret](#_4-chef-s-secret-protip) :::tip TIP This step can be skipped if you are OK with values of `default.json` since it follows the [files load order](https://github.com/lorenwest/node-config/wiki/Configuration-Files#file-load-order) of [node-config](https://github.com/lorenwest/node-config) ::: -3. Run the following Docker command : +3. Run the following Docker command : ```bash docker-compose -f docker-compose.yml -f docker-compose.nodejs.yml up -d ``` -Then the result would look something like this : +Then the result would look something like this : ```bash Building app Step 1/8 : FROM node:10-alpine @@ -107,10 +107,10 @@ warning eslint > file-entry-cache > flat-cache > circular-json@0.3.3: CircularJS ::: 3. In order to verify, run `docker ps` to show which containers are up ```bash -docker ps +docker ps ``` -Then, +Then, ```bash CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 53a47d5a6440 vuestorefrontapi_kibana "/bin/bash /usr/loca…" 31 seconds ago Up 29 seconds 0.0.0.0:5601->5601/tcp vuestorefrontapi_kibana_1 @@ -118,7 +118,7 @@ CONTAINER ID IMAGE COMMAND CREATED 165ae945dbe5 vuestorefrontapi_es1 "/bin/bash bin/es-do…" 8 days ago Up 30 seconds 0.0.0.0:9200->9200/tcp, 0.0.0.0:9300->9300/tcp elasticsearch 8dd144746cef redis:4-alpine "docker-entrypoint.s…" 11 days ago Up 31 seconds 0.0.0.0:6379->6379/tcp vuestorefrontapi_redis_1 ``` -The ports number will be used later in the frontend configuration. In fact, they are already set in as default values. +The ports number will be used later in the frontend configuration. In fact, they are already set in as default values. You will see 4 containers are running, which is : | Container | Port | @@ -141,13 +141,13 @@ cd vue-storefront cp config/default.json config/local.json ``` Then fix the value as you need it in the `local.json` file. -In `local.json`, you may change values for information of backend family. But if you followed this recipe verbatim, you don't have to, because it's already there with the default value. Should you study the contents, please see to [Chef's secret](#secret-1-study-in-local-json) +In `local.json`, you may change values for information of backend family. But if you followed this recipe verbatim, you don't have to, because it's already there with the default value. Should you study the contents, please see to [Chef's secret](#secret-1-study-in-local-json) -6. Finally run the following Docker command : +6. Finally run the following Docker command : ```bash -docker-compose up -d +docker-compose up -d ``` -The result should be something like this : +The result should be something like this : ```bash Building app Step 1/8 : FROM node:10-alpine @@ -200,22 +200,22 @@ de560221fdaf vuestorefrontapi_kibana "/bin/bash /usr/loca…" 8 hours d46c1e0a22af redis:4-alpine "docker-entrypoint.s…" 8 hours ago Up 24 minutes 0.0.0.0:6379->6379/tcp vuestorefrontapi_redis_1 ``` -8. Open your browser and visit [http://localhost:3000/](http://localhost:3000/) +8. Open your browser and visit [http://localhost:3000/](http://localhost:3000/) After compiling, *Voila!* ![vs_home_intro_borderline](../images/home-vuestorefront.png) -### 3. Peep into the kitchen (what happens internally) +### 3. Peep into the kitchen (what happens internally) We used `docker-compose` for setting up the entire environment of Vue Storefront. It was more than enough to launch the machines behind for running the shop. -It was possible because `docker` encapsulated the whole bunch of infrastructure into a linear set of declarative definition for the desired state. +It was possible because `docker` encapsulated the whole bunch of infrastructure into a linear set of declarative definition for the desired state. We had 2 steps of `docker-compose` one of which is for backend **Vue Storefront API**, the other for frontend **Vue Storefront**. The first `docker-compose` had two `yml` files for input. The first input file `docker-compose.yml` describe its base requirement all but **Vue Storefront API** itself; that is, **Elasticsearch** as data store, **Redis** for cache and **Kibana** for helping you grab your data visually (a pair of Elasticsearch). ```yaml -# docker-compose.yml +# docker-compose.yml version: '3.0' services: es1: @@ -247,10 +247,10 @@ services: volumes: esdat1: ``` -:::tip NOTE +:::tip NOTE Once a term explained, it will be ignored thereafter for consecutive occurrence. ::: -`version` denotes which version of `docker-compose` this file uses. +`version` denotes which version of `docker-compose` this file uses. `services` describe containers. It codifies how they should run. In other words, it codifies option flags used with `docker run ...` @@ -258,21 +258,21 @@ Once a term explained, it will be ignored thereafter for consecutive occurrence. - `build` denotes build path of container. - `volumes` contains the mount path of volumes shared between host and container as *host:container* - `ports` connect ports between host and container as in *host:container* -- `environment` allows you to add environment variables. `Xmx512m` means JVM will take up to maximum 512MB memory. `Xms512m` means minimum memory. Combining them, there will be no memory resize, it will just stick to 512MB from start to end throughout its life cycle. +- `environment` allows you to add environment variables. `Xmx512m` means JVM will take up to maximum 512MB memory. `Xms512m` means minimum memory. Combining them, there will be no memory resize, it will just stick to 512MB from start to end throughout its life cycle. `kibana` contains information of *Kibana* application container. -- `depends_on` creates dependency for a container of other containers. So, this container is dependent on `es1` that's just described above. -- `volumes` mean volumes shared, `:ro` creates the volume in `read-only` mode for the container. +- `depends_on` creates dependency for a container of other containers. So, this container is dependent on `es1` that's just described above. +- `volumes` mean volumes shared, `:ro` creates the volume in `read-only` mode for the container. -`redis` contains information of *Redis* cache application container. +`redis` contains information of *Redis* cache application container. -- `image` node contains the name of image this container is based on. +- `image` node contains the name of image this container is based on. -`volumes` in top level can be used as a reference to be used across multiple services(containers). +`volumes` in top level can be used as a reference to be used across multiple services(containers).

-The second input file `docker-compose.nodejs.yml` deals with **Vue Storefront API** node application. +The second input file `docker-compose.nodejs.yml` deals with **Vue Storefront API** node application. ```yaml version: '3.0' services: @@ -301,17 +301,17 @@ services: ports: - '8080:8080' ``` -`app` contains information of *Vue Storefront API* application. +`app` contains information of *Vue Storefront API* application. - `build` is path for build information. If the value is string, it's a plain path. When it's object, you may have a few options to add. `context` is relative path or git repo url where `Dockerfile` is located. `dockerfile` node may change the path/name of `Dockerfile`. [more info](https://docs.docker.com/compose/compose-file/#build) -- `depends_on` tells us this container is based on `es1` and `redis` containers we created above. -- `env_file` helps you add environment values from files. It's relative path from the `docker-compose` file that is in the process, in this case, it's `docker-compose.nodejs.yml` -- `environment` is to set `VS_ENV` as `dev` so that environment will be setup for developer mode. +- `depends_on` tells us this container is based on `es1` and `redis` containers we created above. +- `env_file` helps you add environment values from files. It's relative path from the `docker-compose` file that is in the process, in this case, it's `docker-compose.nodejs.yml` +- `environment` is to set `VS_ENV` as `dev` so that environment will be setup for developer mode. - `tmpfs` denotes temporary volumes that are only available to host memory. Unlike `volumes`, this `tmpfs` will be gone once the container stops. This option is only available to *Linux*.

-The second `docker-compose` step handles **Vue Storefront** frontend. +The second `docker-compose` step handles **Vue Storefront** frontend. ``` yaml version: '2.0' services: @@ -343,32 +343,32 @@ services: ports: - '3000:3000' ``` -This looks like by and large the same with *Vue Storefront API* with a few changes. +This looks like by and large the same with *Vue Storefront API* with a few changes. `app` service describes options for *Vue Storefront* frontend application. - `network_mode` allows you to modify values for `--network` option of docker client. `host` option allows your designated container to open to host network. For example, if you bind your container in host's `80` port, then the container will be accessible at host's `:80` from the internet. In other words, the container is not isolated. [more info](https://docs.docker.com/network/host/) -If you take a closer look inside `Dockerfile`s, you will notice they install all the dependencies of the project from `package.json` not to mention required OS features including `git`, `wget` and certificates. You don't have to worry what to do because we made it do for you. +If you take a closer look inside `Dockerfile`s, you will notice they install all the dependencies of the project from `package.json` not to mention required OS features including `git`, `wget` and certificates. You don't have to worry what to do because we made it do for you. -Next, you might want to import your goods data. Please jump to [Data imports](./data-import.md) if you don't want to stop. +Next, you might want to import your goods data. Please jump to [Data imports](./data-import.md) if you don't want to stop. ### 4. Chef's secret (protip) #### Secret 1. Study in `local.json` for *Vue Storefront API* -Starting point of customization is `default.json` or its copy `local.json` where the platform seeks configuration values. +Starting point of customization is `default.json` or its copy `local.json` where the platform seeks configuration values. :::tip NOTE If you want to modify `default.json`, don't edit it directly but copy the whole file into `local.json` and start editing it in that file. Why it should be done that way is explained later at [Secret 3. Why use node-config?](#secret-3-why-use-node-config) ::: -We have 2 `local.json` files, one of which is for backend here, and we will look at [Secret 2](#secret-2-study-in-local-json-for-vue-storefront), the other for frontend . +We have 2 `local.json` files, one of which is for backend here, and we will look at [Secret 2](#secret-2-study-in-local-json-for-vue-storefront), the other for frontend . -At [`vue-storefront-api/config/default.json`](https://github.com/DivanteLtd/vue-storefront-api/blob/master/config/default.json) for **backend** : +At [`vue-storefront-api/config/default.json`](https://github.com/DivanteLtd/vue-storefront-api/blob/master/config/default.json) for **backend** : ```json "server": { "host": "localhost", "port": 8080, "searchEngine": "elasticsearch" - }, + }, ``` -- This is where your API backend is defined. The server will listen `server.host`:`server.port` unless it's defined otherwise in environment variables. +- This is where your API backend is defined. The server will listen `server.host`:`server.port` unless it's defined otherwise in environment variables. - `server.searchEngine` is used in the integration with `graphql` so please don't change it. [jump to code](https://github.com/DivanteLtd/vue-storefront-api/blob/master/src/graphql/resolvers.js#L6) ```json @@ -381,7 +381,7 @@ At [`vue-storefront-api/config/default.json`](https://github.com/DivanteLtd/vue- ``` - `orders.useServerQueue` allows you to use queue process when `order` API is used to create an order. [jump to code](https://github.com/DivanteLtd/vue-storefront-api/blob/master/src/api/order.js#L65) -- `catalog.excludeDisabledProducts` allows you to skip disabled products when importing products using `mage2vs`. +- `catalog.excludeDisabledProducts` allows you to skip disabled products when importing products using `mage2vs`. [jump to code](https://github.com/DivanteLtd/mage2vuestorefront/blob/master/src/adapters/magento/product.js#L166) ```json @@ -408,21 +408,21 @@ At [`vue-storefront-api/config/default.json`](https://github.com/DivanteLtd/vue- "apiVersion": "5.6" }, ``` -- `elasticsearch` element is used widely across the whole platform. Considering `elasticsearch` works as a data store (database), it's natural. +- `elasticsearch` element is used widely across the whole platform. Considering `elasticsearch` works as a data store (database), it's natural. - - `host`, `port`, `protocol` defines `elasticsearch` connect information. + - `host`, `port`, `protocol` defines `elasticsearch` connect information. - `user`, `password` is default credentials of `elasticsearch`. If you changed the credentials of `elasticsearch`, please change this accordingly. [more info](https://www.elastic.co/guide/en/x-pack/current/security-getting-started.html) - `min_score` sets a `min_score` when building a query for `elasticsearch`. [jump to code](https://github.com/DivanteLtd/vue-storefront-api/blob/master/src/graphql/elasticsearch/queryBuilder.js#L172) :::tip TIP - `min_score` helps you exclude documents with `_score` less than `min_score` value. + `min_score` helps you exclude documents with `_score` less than `min_score` value. ::: - - `indices` may contain one or multiple indexes. Each index acts as a data store for a storefront. You may add entries to the array with arbitrary names or remove entries from it. + - `indices` may contain one or multiple indexes. Each index acts as a data store for a storefront. You may add entries to the array with arbitrary names or remove entries from it. :::warning CAUTION ! - However, the index name should match the one you will use for [data pump](data-import.md#_2-2-recipe-b-using-on-premise). + However, the index name should match the one you will use for [data pump](data-import.md#_2-2-recipe-b-using-on-premise). ::: - The default values for `indices` assume you have 2 additional stores(`de`, `it`) plus the default store. + The default values for `indices` assume you have 2 additional stores(`de`, `it`) plus the default store. - `indexTypes` contains values for mapping. You can consider it as `table` if you take `indices` as database. - - `apiVersion` defines the `elasticsearch` version it uses. + - `apiVersion` defines the `elasticsearch` version it uses. ```json "redis": { @@ -441,7 +441,7 @@ At [`vue-storefront-api/config/default.json`](https://github.com/DivanteLtd/vue- "it" ], ``` -- `availableStores` contains additional stores code name. If this value is an empty array, it means you only have one default store. +- `availableStores` contains additional stores code name. If this value is an empty array, it means you only have one default store. ```json "storeViews": { @@ -506,38 +506,38 @@ At [`vue-storefront-api/config/default.json`](https://github.com/DivanteLtd/vue- ``` - `storeViews` element contains the whole information of ***additional*** stores. The default store information doesn't exist here, it exists on top level. -- `multistore` is supposed to tell the platform if it has multiple stores to consider. For example, it is used to configure `tax` values of additional store. [jump to code](https://github.com/DivanteLtd/vue-storefront-api/blob/master/src/platform/magento2/tax.js#L14) +- `multistore` is supposed to tell the platform if it has multiple stores to consider. For example, it is used to configure `tax` values of additional store. [jump to code](https://github.com/DivanteLtd/vue-storefront-api/blob/master/src/platform/magento2/tax.js#L14) - `mapStoreUrlsFor` is used for building url routes in frontend. [jump to code](https://github.com/DivanteLtd/vue-storefront/blob/master/core/lib/multistore.ts#L85) -- `de` element contains detailed information of `de` store. You need to have this kind of element for all the additional stores you added to `availableStores` with `storeCode` as the key. `de` and `it` in the `default.json` exhibits an example you can copy & paste for other stores you need to add. - - `storeCode` denotes store code for the store. +- `de` element contains detailed information of `de` store. You need to have this kind of element for all the additional stores you added to `availableStores` with `storeCode` as the key. `de` and `it` in the `default.json` exhibits an example you can copy & paste for other stores you need to add. + - `storeCode` denotes store code for the store. - `storeId` denotes store ID of the store. - `name` denotes the store name. - `url` denotes URL for the store. - - `elasticsearch` contains information for the store. This information may override the default one defined above. + - `elasticsearch` contains information for the store. This information may override the default one defined above. - `host` is where your *Elasticsearch* listens on. - `index` is the name of the index for the store. - `tax` contains tax information of the store. - - `defaultCountry` is the code name of the country on which tax is calculated for the store. + - `defaultCountry` is the code name of the country on which tax is calculated for the store. - `defaultRegion` is default region. - `calculateServerSide` determines if price is fetched with(`true`)/without(`false`) tax calculated. [jump to code](https://github.com/DivanteLtd/vue-storefront-api/blob/master/src/api/product.js#L48) - `sourcePriceIncludesTax` determines whether price is stored with tax applied (`true`) or tax calculated on runtime (`false`). [jump to code](https://github.com/DivanteLtd/vue-storefront-api/blob/master/src/platform/magento2/tax.js#L12) - - `i18n` connotes *internationalization*. [more info](https://en.wikipedia.org/wiki/Internationalization_and_localization) + - `i18n` connotes *internationalization*. [more info](https://en.wikipedia.org/wiki/Internationalization_and_localization) - `fullCountryName` is the full name of the country this `i18n` is applied to. - `fullLanguageName` is the full name of the language this `i18n` is applied to. - `defaultCountry` is the abbreviated name of the country this `i18n` is applied to by default. - `defaultLanguage` is the abbreviated name of the language this `i18n` is applied to by default. - - `defaultLocale` is the default locale this `i18n` uses. - - `currencyCode` is the currency code this store uses. - - `currencySign` is the currency sign this store uses. - - `dateFormat` is the date format this store uses. - - + - `defaultLocale` is the default locale this `i18n` uses. + - `currencyCode` is the currency code this store uses. + - `currencySign` is the currency sign this store uses. + - `dateFormat` is the date format this store uses. + + ```json "authHashSecret": "__SECRET_CHANGE_ME__", "objHashSecret": "__SECRET_CHANGE_ME__", ``` -- `authHashSecret` is used to encode & decode JWT for API use. -- `objHashSecret` is 1) fallback secret hash for `authHashSecret`, 2) used for hashing in tax calculation. +- `authHashSecret` is used to encode & decode JWT for API use. +- `objHashSecret` is 1) fallback secret hash for `authHashSecret`, 2) used for hashing in tax calculation. ```json "cart": { @@ -553,20 +553,20 @@ At [`vue-storefront-api/config/default.json`](https://github.com/DivanteLtd/vue- "sourcePriceIncludesTax": false }, ``` -- `cart` +- `cart` - `setConfigurableProductOptions` flag determines to show either the parent item or the child item (aka selected option item) in the cart context. `true` shows parent item instead of the option item selected. [jump to code](https://github.com/DivanteLtd/vue-storefront-api/blob/master/src/platform/magento2/o2m.js#L94) - `tax` - `alwaysSyncPlatformPricesOver` [jump to code](https://github.com/DivanteLtd/vue-storefront-api/blob/master/src/api/order.js#L49) - - `usePlatformTotals` - These two options are used to determine whether to fetch prices from data source on the fly or not. If you set `alwaysSyncPlatformPricesOver` true, then it skips checking the checksum for cart items based on price. - + - `usePlatformTotals` + These two options are used to determine whether to fetch prices from data source on the fly or not. If you set `alwaysSyncPlatformPricesOver` true, then it skips checking the checksum for cart items based on price. + ```json "bodyLimit": "100kb", "corsHeaders": [ "Link" ], ``` -- `bodyLimit` limits how big a request can be for your application. +- `bodyLimit` limits how big a request can be for your application. - `corsHeaders` allows you to add entries to `Access-Control-Expose-Headers` ```json @@ -606,26 +606,26 @@ At [`vue-storefront-api/config/default.json`](https://github.com/DivanteLtd/vue- - `mailchimp` provides `POST`, `DELETE` APIs for *Mailchimp* `subscribe` method. - `listId` is the ID of list you are publishing. - `apiKey` is API key you are assigned. - - `apiUrl` is API base url for *Mailchimp* service. + - `apiUrl` is API base url for *Mailchimp* service. - `mailService` is used to send emails from Vue Storefront via *Gmail*. - `transport` contains basic information for *Gmail* service. - `host` is where your mail is sent en route. - `port` is the port number used for the service. - - `secure` determines to use SSL connection. + - `secure` determines to use SSL connection. - `user` is `username` for the service. - `pass` is `password` for the service. - - `targetAddressWhitelist` checks if an user confirmed his/her email address *and* source email is white-listed. - - `secretString` is used for hashing. + - `targetAddressWhitelist` checks if an user confirmed his/her email address *and* source email is white-listed. + - `secretString` is used for hashing. ```json "magento2": { - "url": "http://demo-magento2.vuestorefront.io/", - "imgUrl": "http://demo-magento2.vuestorefront.io/media/catalog/product", - "assetPath": "/../var/magento2-sample-data/pub/media", - "magentoUserName": "", - "magentoUserPassword": "", - "httpUserName": "", - "httpUserPassword": "", + "url": "http://demo-magento2.vuestorefront.io/", + "imgUrl": "http://demo-magento2.vuestorefront.io/media/catalog/product", + "assetPath": "/../var/magento2-sample-data/pub/media", + "magentoUserName": "", + "magentoUserPassword": "", + "httpUserName": "", + "httpUserPassword": "", "api": { "url": "http://demo-magento2.vuestorefront.io/rest", "consumerKey": "byv3730rhoulpopcq64don8ukb8lf2gq", @@ -650,26 +650,26 @@ At [`vue-storefront-api/config/default.json`](https://github.com/DivanteLtd/vue- } }, ``` -- `magento2` is used to integrate with Magento 2 as a data source. - +- `magento2` is used to integrate with Magento 2 as a data source. + - `imgUrl` is base image url. [jump to code](https://github.com/kkdg/vue-storefront-api/blob/master/src/api/img.js#L38) - + - `assetPath` is used for the `media` path. [jump to code](https://github.com/kkdg/vue-storefront-api/blob/master/src/index.js#L22) - + - `api` contains API credentials for integration. - + - `url` is base url for Magento 2 instance. - `consumerKey` See **TIP** - `consumerSecret` - `accessToken` - `accessTokenSecret` - - - + + + :::tip TIP - + These 4 nodes above is the required credentials for integration with Magento 2. [how to get them](data-import.html#_2-2-recipe-b-using-on-premise) - + ::: `magento1` has just the same structure with `magento2`. @@ -678,7 +678,7 @@ At [`vue-storefront-api/config/default.json`](https://github.com/DivanteLtd/vue- ```json "imageable": { - "namespace": "", + "namespace": "", "maxListeners": 512, "imageSizeLimit": 1024, "whitelist": { @@ -698,44 +698,44 @@ At [`vue-storefront-api/config/default.json`](https://github.com/DivanteLtd/vue- "process": 4 }, "simd": true, - "keepDownloads": true + "keepDownloads": true }, ``` -- `imageable` deals with everything you need to configure when it comes to your storefront images, especially product images. - +- `imageable` deals with everything you need to configure when it comes to your storefront images, especially product images. + - `maxListeners` limits maximum listeners to request's socket. [jump to code](https://github.com/DivanteLtd/vue-storefront-api/blob/master/src/api/img.js#L21) - `imageSizeLimit` limits maximum image size. [jump to code](https://github.com/DivanteLtd/vue-storefront-api/blob/master/src/api/img.js#L56) - `whitelist` contains a white-list of image source domains - + - `allowedHosts` contains the array of white-list - + :::warning DON'T FORGET - + You should include your source domain in `allowedHosts` or your request for product images will fail. [more info](data-import.html#secret-1-product-image-is-not-synced) - + ::: - + :::tip NOTE - - From `cache` to `simd` they are used to configure [Sharp](https://github.com/lovell/sharp) library. *Sharp* is a popular library for image processing in *Node.js*. [jump to option docs](https://sharp.dimens.io/en/stable/api-utility/#cache) - + + From `cache` to `simd` they are used to configure [Sharp](https://github.com/lovell/sharp) library. *Sharp* is a popular library for image processing in *Node.js*. [jump to option docs](https://sharp.dimens.io/en/stable/api-utility/#cache) + ::: - - - `cache` limits `libvips` operation cache from *Sharp*. Values hereunder are default values. [jump to code](https://github.com/DivanteLtd/vue-storefront-api/blob/master/src/lib/image.js#L5) - - - `memory` is the maximum memory in MB to use for the cache. - - `files` is the maximum number of files to hold open. - - `items` is the maximum number of operations to cache. - - - `concurrency` is the number of threads for processing each image. - - - `counters` provides access to internal task counters. - - - `queue` is the number of tasks in queue for *libuv* to provide a worker thread. - - `process` limits the number of resize tasks concurrently processed. - - - `simd` to use SIMD vector unit of the CPU in order to enhance the performance. - + + - `cache` limits `libvips` operation cache from *Sharp*. Values hereunder are default values. [jump to code](https://github.com/DivanteLtd/vue-storefront-api/blob/master/src/lib/image.js#L5) + + - `memory` is the maximum memory in MB to use for the cache. + - `files` is the maximum number of files to hold open. + - `items` is the maximum number of operations to cache. + + - `concurrency` is the number of threads for processing each image. + + - `counters` provides access to internal task counters. + + - `queue` is the number of tasks in queue for *libuv* to provide a worker thread. + - `process` limits the number of resize tasks concurrently processed. + + - `simd` to use SIMD vector unit of the CPU in order to enhance the performance. + ```json @@ -764,13 +764,13 @@ At [`vue-storefront-api/config/default.json`](https://github.com/DivanteLtd/vue- } }, ``` -- `entities` is used to integrate with *GraphQL* in **Vue Storefront API**. +- `entities` is used to integrate with *GraphQL* in **Vue Storefront API**. - `category` - `includeFields` contains an array of fields to be added as `sourceInclude` [jump to code](https://github.com/DivanteLtd/vue-storefront-api/blob/master/src/graphql/elasticsearch/category/resolver.js#L10) - `product` - `filterFieldMapping` adds a field mapping to apply a filter in a query [jump to code](https://github.com/DivanteLtd/vue-storefront-api/blob/master/src/graphql/elasticsearch/mapping.js#L19) - - `category.name` - + - `category.name` + ```json "usePriceTiers": false, "boost": { @@ -783,17 +783,17 @@ At [`vue-storefront-api/config/default.json`](https://github.com/DivanteLtd/vue- } ``` - `usePriceTiers` determines whether to use price tiers for customers in groups -- `boost` is used to give weighted values to fields for a query to *Elasticsearch*, the bigger, the heavier. +- `boost` is used to give weighted values to fields for a query to *Elasticsearch*, the bigger, the heavier. - `name` field has the value *3* so that matching query with the `name` has the highest priority. - - `category.name` ,`short_description`, `description`, `sku`, `configurable_children.sku ` the rest of fields have the default value; 1. - + - `category.name` ,`short_description`, `description`, `sku`, `configurable_children.sku ` the rest of fields have the default value; 1. +

#### Secret 2. Study in `local.json` for *Vue Storefront* -At [`vue-storefront/config/default.json`](https://github.com/DivanteLtd/vue-storefront/blob/master/config/default.json) for **frontend** : +At [`vue-storefront/config/default.json`](https://github.com/DivanteLtd/vue-storefront/blob/master/config/default.json) for **frontend** : ```json "server": { @@ -816,43 +816,43 @@ At [`vue-storefront/config/default.json`](https://github.com/DivanteLtd/vue-stor ``` - `server` contains information of various features related to *frontend* server. - - - `host` is the host address in which your *Vue Storefront* instance starts at. - - - `port` is the port number in which your *Vue Storefront* instance listens to. - + + - `host` is the host address in which your *Vue Storefront* instance starts at. + + - `port` is the port number in which your *Vue Storefront* instance listens to. + - `protocol` is used for *GraphQL* integration. [jump to code](https://github.com/DivanteLtd/vue-storefront/blob/master/core/lib/search/adapter/graphql/searchAdapter.ts#L48) - + - `api` determines API mode between `api` and `graphql`. [jump to code](https://github.com/DivanteLtd/vue-storefront/blob/master/core/scripts/resolvers/resolveGraphQL.js#L7) - + :::tip TIP - - You may take a look at [*GraphQL Action Plan*](/guide/basics/graphql.html) guide to help yourself make a decision which mode you should take. + + You may take a look at [*GraphQL Action Plan*](/guide/basics/graphql.html) guide to help yourself make a decision which mode you should take. ::: - + - `devServiceWorker` enables *service worker* in `develop` mode. The *service worker* is normally enabled by default for `production` mode, but not for `develop` mode. Setting this flag *true* forces to use *service worker* in `develop` mode too. [jump to code](https://github.com/DivanteLtd/vue-storefront/blob/master/core/service-worker/registration.js#L5) :::tip TIP - - You may take a look at [Working with Service Workers](/guide/core-themes/service-workers.html) for better understanding. - ::: - + + You may take a look at [Working with Service Workers](/guide/core-themes/service-workers.html) for better understanding. + ::: + - `useOutputCacheTagging` determines to allow *Output Cache Tags*. [jump to code](https://github.com/DivanteLtd/vue-storefront/blob/master/core/scripts/server.js#L168) - + - `useOutputCache` determines to allow *Output Cache*. [jump to code](https://github.com/DivanteLtd/vue-storefront/blob/master/core/scripts/server.js#L64) - + - `outputCacheDefaultTtl` defines the default timeout for *Redis Tag Cache*. [jump to code](https://github.com/DivanteLtd/vue-storefront/blob/master/core/scripts/utils/cache-instance.js#L16) - + - `availableCacheTags` contains a list of available cache tags. [jump to code](https://github.com/DivanteLtd/vue-storefront/blob/master/core/scripts/cache.js#L7) - + - `invalidateCacheKey` is the key used for checking validity of invalidation. [jump to code](https://github.com/DivanteLtd/vue-storefront/blob/master/core/scripts/server.js#L66) :::tip TIP - + You may take a look at [SSR Cache](/guide/basics/ssr-cache.html) in order to grab the idea of *Output Cache* in *Vue Storefront* ::: - + - `dynamicConfigReload` enables to reload `config.json` on the fly with each server request. [jump to code](https://github.com/DivanteLtd/vue-storefront/blob/master/core/scripts/server.js#L232) - `dynamicConfigContinueOnError` allows to skip errors during configuration merge on the fly. [jump to code](https://github.com/DivanteLtd/vue-storefront/blob/master/core/scripts/server.js#L240) - - `dynamicConfigExclude` + - `dynamicConfigExclude` - `dynamicConfigInclude` - `elasticCacheQuota` @@ -860,7 +860,7 @@ At [`vue-storefront/config/default.json`](https://github.com/DivanteLtd/vue-stor ```json "seo": { "useUrlDispatcher": true -}, +}, "console": { "showErrorOnProduction" : true, "verbosityLevel": "display-everything" @@ -905,9 +905,9 @@ At [`vue-storefront/config/default.json`](https://github.com/DivanteLtd/vue-stor "cutoff_frequency": 0.01, "max_expansions": 3, "minimum_should_match": "75%", - "prefix_length": 2, + "prefix_length": 2, "boost_mode": "multiply", - "score_mode": "multiply", + "score_mode": "multiply", "max_boost": 100, "function_min_score": 1 }, @@ -939,7 +939,7 @@ At [`vue-storefront/config/default.json`](https://github.com/DivanteLtd/vue-stor "useInitialStateFilter": true }, ``` -- `ssr` +- `ssr` - `templates` - `default` @@ -1229,7 +1229,7 @@ At [`vue-storefront/config/default.json`](https://github.com/DivanteLtd/vue-stor }, ``` - `tax`: ... - - `defaultCountry` is the code name of the country on which tax is calculated for the store. + - `defaultCountry` is the code name of the country on which tax is calculated for the store. - `defaultRegion` is default region. - `sourcePriceIncludesTax` determines whether price is stored with tax applied (`true`) or tax calculated on runtime (`false`). [jump to code](https://github.com/DivanteLtd/vue-storefront-api/blob/master/src/platform/magento2/tax.js#L12) - `calculateServerSide` determines if price is fetched with(`true`)/without(`false`) tax calculated. [jump to code](https://github.com/DivanteLtd/vue-storefront-api/blob/master/src/api/product.js#L48) @@ -1258,6 +1258,9 @@ At [`vue-storefront/config/default.json`](https://github.com/DivanteLtd/vue-stor "defaultLocale": "en-US", "currencyCode": "USD", "currencySign": "$", + "currencyDecimal": null, + "currencyGroup": null, + "fractionDigits": 2, "priceFormat": "{sign}{amount}", "dateFormat": "HH:mm D/M/YYYY", "fullCountryName": "United States", @@ -1356,58 +1359,58 @@ Sometimes we need to know the inside of the perfect machine so that we can prepa

-## 4. Storefront CLI at your service -Upon the release of 1.10, we also present a new way of setup and all its sorts from `CLI` which is the all-time most favorite tool of developers worldwide if I must say. There are lots of benefits when `CLI` methods are available such as automation in scripts in cooperation with other automation tools out there. +## 4. Storefront CLI at your service +Upon the release of 1.10, we also present a new way of setup and all its sorts from `CLI` which is the all-time most favorite tool of developers worldwide if I must say. There are lots of benefits when `CLI` methods are available such as automation in scripts in cooperation with other automation tools out there. -We will continuously add new features to [`CLI`](https://www.npmjs.com/package/@vue-storefront/cli) as the version goes up. +We will continuously add new features to [`CLI`](https://www.npmjs.com/package/@vue-storefront/cli) as the version goes up. ### 1. Preparation - You need to have installed [`npm`](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) on your machine and [`yarn`](https://yarnpkg.com/lang/en/docs/install/#debian-stable). ### 2. Recipe -1. Install _Vue Storefront CLI_ package on your machine with `-g` flag as follows : +1. Install _Vue Storefront CLI_ package on your machine with `-g` flag as follows : ```bash npm install -g @vue-storefront/cli@0.0.15 ``` :vhs: You may also watch it in [bash playback :movie_camera:](https://asciinema.org/a/ZK0BVF7cQ8OaHHRcsaZgcOCfN) -2. Now go to any random folder you want to install a _Vue Storefront_ app under, and run the following : +2. Now go to any random folder you want to install a _Vue Storefront_ app under, and run the following : ```bash vsf init ``` -3. You will encounter a series of questions to install the app, first of which is as follows : +3. You will encounter a series of questions to install the app, first of which is as follows : ```bash ✔ Check avalilable versions -? Which version of Vue Storefront you'd like to install? -❯ Stable versions (recommended for production) - Release Candidates +? Which version of Vue Storefront you'd like to install? +❯ Stable versions (recommended for production) + Release Candidates In development branches (could be unstable!) ``` Select an option based on which you are to install. -4. Next question is about specific version to be installed as follows : +4. Next question is about specific version to be installed as follows : ```bash -? Select specific version - v1.8.0 -❯ v1.10.0 - v1.9.2 - v1.9.1 - v1.9.0 - v1.8.5 - v1.8.4 +? Select specific version + v1.8.0 +❯ v1.10.0 + v1.9.2 + v1.9.1 + v1.9.0 + v1.8.5 + v1.8.4 ``` Choose a version of your target. -5. Next question is about how you install it between `installer`/`manual` like below : +5. Next question is about how you install it between `installer`/`manual` like below : ```bash -? Would you like to use friendly installer or install Vue Storefront manually? -❯ Installer (MacOS/Linux only) +? Would you like to use friendly installer or install Vue Storefront manually? +❯ Installer (MacOS/Linux only) Manual installation ``` -Let's pick the `Installer` option for now. +Let's pick the `Installer` option for now. -Then you will see the machine start working on installation : +Then you will see the machine start working on installation : ```bash ? Would you like to use friendly installer or install Vue Storefront manually? Installer (MacOS/Linux only) ✔ Copying Vue Storefront files @@ -1415,7 +1418,7 @@ Then you will see the machine start working on installation : Running installer ``` -6. Once the preparation is finished then another series of questions pops up as `installer` is associated with as follows : +6. Once the preparation is finished then another series of questions pops up as `installer` is associated with as follows : ```bash yarn run v1.17.3 $ node ./core/scripts/installer @@ -1427,20 +1430,20 @@ $ node ./core/scripts/installer ? Would you like to use https://demo.vuestorefront.io as the backend? (Y/n) ``` -From this on, the questions would be the same as installation through `installer`. +From this on, the questions would be the same as installation through `installer`. You can follow it further at [Install using installer](#_2-using-installer) 7. Once the questions have been answered then the remaining process is taken to action. You will see a screen as follows when they finished : ```bash ? Please provide path for images endpoint https://demo.vuestorefront.io/img/ - Trying to create log files... + Trying to create log files... - Creating storefront config 'config/local.json'... + Creating storefront config 'config/local.json'... - Build storefront npm... + Build storefront npm... - Starting storefront server... + Starting storefront server... ┌────────────────────────────────────────────────────┐ │ Congratulations! │ @@ -1463,10 +1466,10 @@ You can follow it further at [Install using installer](#_2-using-installer) ![home_borderline](../images/home-vuestorefront.png) -Congratulation! +Congratulation! ### 3. Peep into the kitchen (what happens internally) -_Vue Storefront_ people prepared the `CLI` way of installing the whole infrastructure for your _Vue Storefront_ app provided as an `npm` package. It's now as easy as to install an `npm` package on any machine. Installed then run a command with a few options would be more than enough for the app to be up and running. Believe me your next _Vue Storefront_ app will be with you instantly with a breeze as long as `CLI` is accessible. +_Vue Storefront_ people prepared the `CLI` way of installing the whole infrastructure for your _Vue Storefront_ app provided as an `npm` package. It's now as easy as to install an `npm` package on any machine. Installed then run a command with a few options would be more than enough for the app to be up and running. Believe me your next _Vue Storefront_ app will be with you instantly with a breeze as long as `CLI` is accessible. ### 4. Chef's secret (protip) #### Secret 1. Install with _manual_ path @@ -1480,7 +1483,7 @@ _Vue Storefront_ people prepared the `CLI` way of installing the whole infrastru
## 5. How to debug *Anything* -When it comes to developing a software, there is one thing you really need to know. Knowing the blocker. Fixing a problem means you already know what's going on before what went wrong, and most of the time your pains are from yourself failing at that, which is, you don't know the problem and that is the problem. +When it comes to developing a software, there is one thing you really need to know. Knowing the blocker. Fixing a problem means you already know what's going on before what went wrong, and most of the time your pains are from yourself failing at that, which is, you don't know the problem and that is the problem. ### 1. Preparation ### 2. Recipe ### 3. Peep into the kitchen (what happens internally) diff --git a/docs/guide/data/entity-types.md b/docs/guide/data/entity-types.md index 975371d74..71db8f852 100644 --- a/docs/guide/data/entity-types.md +++ b/docs/guide/data/entity-types.md @@ -38,7 +38,7 @@ searchAdapter.registerEntityTypeByQuery('testentity', { throw new Error(JSON.stringify(resp.error)); } else { throw new Error( - "Unknown error with graphQl result in resultPorcessor for entity type 'category'", + "Unknown error with graphQl result in resultProcessor for entity type 'category'", ); } } diff --git a/docs/guide/general/introduction.md b/docs/guide/general/introduction.md index ffde3c29b..31f9e4a5f 100644 --- a/docs/guide/general/introduction.md +++ b/docs/guide/general/introduction.md @@ -86,7 +86,11 @@ The mechanism of injecting core business logic into themes is ridiculously simpl So assume we have a core Microcart component with business logic as above (left side), we can easily inject it into any of our theme components (right side) just by importing it and adding as a mixin `mixins: [Microcart]`. This is all you need to make use of core business logic inside your theme. With this approach, we can easily ship updates to all core components without breaking your shop. -The easiest way to create your own theme is to create a copy of the default one, change its name in its `package.json` file, change the active theme in `config/local.json` and run `yarn` to make [Lerna](https://github.com/lerna/lerna) linking (which we use for monorepos). +The easiest way to create your own theme is to create a copy from one of our official themes, change its name in its `package.json` file, change the active theme in `config/local.json` and run `yarn` to make [Lerna](https://github.com/lerna/lerna) linking (which we use for monorepos). + +Our official themes: +- Capybara - https://github.com/DivanteLtd/vsf-capybara +- Default - https://github.com/DivanteLtd/vsf-default ## Offline mode and cache Vue Storefront still works even while the user is offline. diff --git a/docs/guide/installation/windows.md b/docs/guide/installation/windows.md index 152f862d2..87bf20f10 100644 --- a/docs/guide/installation/windows.md +++ b/docs/guide/installation/windows.md @@ -77,24 +77,6 @@ yarn install ``` 5. Copy `config/default.json` to `config/local.json` -6. Images: because `vue-storefront-api` uses `imagemagick` and some nodejs command line bindings, it can be difficult to run the image proxy on a localhost/Windows machine. Please point out the `vue-storefront` to image proxy provided by changing `config/local.json` `images.baseUrl`: - -```js -export default { - elasticsearch: { - httpAuth: '', - host: 'localhost:8080/api/catalog', - index: 'vue_storefront_catalog', - }, - // we have vue-storefront-api (https://github.com/DivanteLtd/vue-storefront-api) endpoints below: - orders: { - endpoint: 'localhost:8080/api/order/create', - }, - images: { - baseUrl: 'https://demo.vuestorefront.io/img/', - }, -}; -``` :::tip NOTE We're using the powerful node.js library for config files. Check the docs to learn more about it: [https://github.com/lorenwest/node-config](https://github.com/lorenwest/node-config) diff --git a/docs/guide/upgrade-notes/README.md b/docs/guide/upgrade-notes/README.md index 5034414a4..6d2643af9 100644 --- a/docs/guide/upgrade-notes/README.md +++ b/docs/guide/upgrade-notes/README.md @@ -2,6 +2,73 @@ We're trying to keep the upgrade process as easy as possible. Unfortunately, sometimes manual code changes are required. Before pulling out the latest version, please take a look at the upgrade notes below: +## 1.11 -> 1.12 + +Most of the changes added to 1.12 are backward compatible. To enable the new features (mostly the optimization features) please follow the steps described below. + +**Remove bodybuilder and compact API responses** + +The new search adapter `api-search-query` has been added. When you switch to it, by setting the `config.server.api = "api-search-query"` the ElasticSearch query is being built in the [`vue-storefront-api`](https://github.com/DivanteLtd/vue-storefront-api/pull/390) which saves around 400kB in the bundle size as `bodybuilder` is no longer needed in the frontend. + +This new `api-search-query` adapter supports the `response_format` query parameter which now is sent to the `/api/catalog` endpoint. Currently there is just one additional format supported: `response_format=compact`. When used, the response format got optimized by: a) remapping the results, removing the `_source` from the `hits.hits`; b) compressing the JSON fields names according to the `config.products.fieldsToCompact`; c) removing the JSON fields from the `product.configurable_children` when their values === parent product values; overall response size reduced over -70%. + +**Re-enable amp-renderer** + +The `amp-renderer` module has been disabled by default to save the bundle size; If you'd like to enable it uncomment the module from the `src/modules` and uncomment the `product-amp` and `category-amp` links that are added to the `` section in the `src/themes/default/Product.vue` and `src/themes/default/Category.vue` + +**Check entity optimization settings** + +Cart optimization was earlier disabled automatically if entity optimization was disabled. Now they can be used independently from each other. If you don't want to use cart optimization, make sure that the `entities.optimizeShoppingCart` configuration entry is disabled explicitly. + +**deprecated actions and helpers** +Product module has been refactored, here is list of actions that are not used anymore and you can remove them to reduce bundle. +deprecated actions: +product/reset +product/setupBreadcrumbs +product/syncPlatformPricesOver +product/setupAssociated +product/loadConfigurableAttributes +product/setupVariants +product/filterUnavailableVariants +product/list +product/preConfigureAssociated +product/preConfigureProduct +product/configureLoadedProducts +product/configureBundleAsync +product/configureGroupedAsync +product/configure +product/setCurrentOption +product/setCurrentErrors +product/setOriginal +product/loadProductAttributes +category/list (new action is category-next/fetchMenuCategories) + +deprecated helpers: +configureProductAsync +populateProductConfigurationAsync +setConfigurableProductOptionsAsync + +Here is list of actions that are used from 1.12 in product module: +product/doPlatformPricesSync +product/single +product/checkConfigurableParent +product/findProducts +product/findConfigurableParent +product/setCustomOptions +product/setBundleOptions +product/setCurrent +product/loadProduct +product/addCustomOptionValidator +product/setProductGallery +product/loadProductBreadcrumbs +product/getProductVariant + +All of those actions and helpers that are deprecated, can be removed so you will have smaller bundle. +Comment those lines: +- core/modules/catalog/store/product/actions.ts:318 +- core/modules/catalog/helpers/index.ts:14-18 +- core/modules/catalog-next/store/category/actions.ts:265 + ## 1.10 -> 1.11 This is the last major release of Vue Storefront 1.x before 2.0 therefore more manual updates are required to keep external packages compatible with 1.x as long as possible. diff --git a/docs/package.json b/docs/package.json index f1a99307a..6f2c15ddb 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,7 +1,7 @@ { "name": "@vue-storefront/docs", "private": true, - "version": "1.11.4", + "version": "1.12.0", "scripts": { "docs:dev": "vuepress dev", "docs:build": "vuepress build", diff --git a/package.json b/package.json old mode 100755 new mode 100644 index 5d319408d..21a120e94 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vue-storefront", - "version": "1.11.4", + "version": "1.12.0", "description": "A Vue.js, PWA eCommerce frontend", "private": true, "engines": { @@ -28,19 +28,20 @@ "scripts": { "static-server": "cross-env TS_NODE_PROJECT=\"tsconfig-build.json\" ts-node ./core/scripts/static-server.ts", "generate": "cross-env TS_NODE_PROJECT=\"tsconfig-build.json\" ts-node ./core/scripts/generate.ts", + "generate-files": "cross-env TS_NODE_PROJECT=\"tsconfig-build.json\" ts-node ./core/scripts/generate-files.ts", "start": "cross-env NODE_ENV=production TS_NODE_PROJECT=\"tsconfig-build.json\" pm2 start ecosystem.json $PM2_ARGS", "start:inspect": "cross-env NODE_ENV=production TS_NODE_PROJECT=\"tsconfig-build.json\" node --inspect -r ts-node/register ./core/scripts/server", "installer": "node ./core/scripts/installer", "installer:ci": "yarn installer --default-config", "all": "cross-env NODE_ENV=development node ./core/scripts/all", "cache": "node ./core/scripts/cache", - "dev": "cross-env TS_NODE_PROJECT=\"tsconfig-build.json\" ts-node ./core/scripts/server.ts", - "dev:sw": "cross-env TS_NODE_PROJECT=\"tsconfig-build.json\" yarn build:sw && yarn dev", - "dev:inspect": "cross-env TS_NODE_PROJECT=\"tsconfig-build.json\" node --inspect -r ts-node/register ./core/scripts/server", + "dev": "yarn generate-files && cross-env TS_NODE_PROJECT=\"tsconfig-build.json\" ts-node ./core/scripts/server.ts", + "dev:sw": "yarn generate-files && cross-env TS_NODE_PROJECT=\"tsconfig-build.json\" yarn build:sw && yarn dev", + "dev:inspect": "yarn generate-files && cross-env TS_NODE_PROJECT=\"tsconfig-build.json\" node --inspect -r ts-node/register ./core/scripts/server", "build:sw": "cross-env NODE_ENV=production TS_NODE_PROJECT=\"tsconfig-build.json\" webpack --config ./core/build/webpack.prod.sw.config.ts --mode production --progress --hide-modules", "build:client": "cross-env NODE_ENV=production TS_NODE_PROJECT=\"tsconfig-build.json\" webpack --config ./core/build/webpack.prod.client.config.ts --mode production --progress --hide-modules", "build:server": "cross-env NODE_ENV=production TS_NODE_PROJECT=\"tsconfig-build.json\" webpack --config ./core/build/webpack.prod.server.config.ts --mode production --progress --hide-modules", - "build": "rimraf dist && yarn build:client && yarn build:server && yarn build:sw", + "build": "rimraf dist && yarn generate-files && npm-run-all -p build:*", "test:unit": "jest -c test/unit/jest.conf.js", "test:unit:watch": "jest -c test/unit/jest.conf.js --watch", "test:e2e": "cypress open", @@ -78,6 +79,7 @@ "redis-tag-cache": "^1.2.1", "reflect-metadata": "^0.1.12", "register-service-worker": "^1.5.2", + "storefront-query-builder": "https://github.com/DivanteLtd/storefront-query-builder.git", "ts-node": "^8.6.2", "vue": "^2.6.11", "vue-analytics": "^5.16.1", @@ -98,10 +100,10 @@ "vuex-router-sync": "^5.0.0" }, "devDependencies": { - "@babel/core": "^7.8.6", + "@babel/core": "^7.9.0", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/polyfill": "^7.8.3", - "@babel/preset-env": "^7.8.6", + "@babel/preset-env": "^7.9.0", "@types/jest": "^25.1.3", "@types/node": "^13.7.7", "@typescript-eslint/eslint-plugin": "^1.7.1-alpha.17", @@ -146,6 +148,7 @@ "lint-staged": "^8.2.1", "mkdirp": "^0.5.1", "node-sass": "^4.12.0", + "npm-run-all": "^4.1.5", "phantomjs-prebuilt": "^2.1.10", "postcss-flexbugs-fixes": "^4.1.0", "postcss-loader": "^3.0.0", diff --git a/packages/cli/boilerplates/module/package.json b/packages/cli/boilerplates/module/package.json index 7f1745975..bcaa84869 100644 --- a/packages/cli/boilerplates/module/package.json +++ b/packages/cli/boilerplates/module/package.json @@ -12,13 +12,13 @@ "license": "MIT", "dependencies": {}, "devDependencies": { - "@vue-storefront/core": "^1.11.1", + "@vue-storefront/core": "^1.12.0", "ts-loader": "^6.0.4", "typescript": "^3.5.2", "webpack": "^4.35.2", "webpack-cli": "^3.3.11" }, "peerDependencies": { - "@vue-storefront/core": "^1.11.1" + "@vue-storefront/core": "^1.12.0" } } diff --git a/packages/cli/index.js b/packages/cli/index.js index cece1378d..e2dc45e53 100755 --- a/packages/cli/index.js +++ b/packages/cli/index.js @@ -9,11 +9,13 @@ switch (command) { case 'init:module': require('./scripts/generateModule.js')(process.argv[3]) break; + case '-h': case '--help': require('./scripts/manual.js')() break; + case '-v': case '--version': - console.log('v' + require('../package.json').version) + console.log('v' + require('./package.json').version) break; default: console.log('Unknown command. try one of those:\n') diff --git a/packages/cli/package.json b/packages/cli/package.json index 3fe495996..433d0b6ec 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -17,7 +17,8 @@ "fs-extra": "^8.1.0", "inquirer": "^6.3.1", "listr": "^0.14.3", + "lodash": "^4.17.15", "replace-in-file": "^4.1.1", - "semver-sort": "^0.0.4" + "semver": "^7.1.3" } } diff --git a/packages/cli/scripts/install.js b/packages/cli/scripts/install.js index f1e1040ee..6d3192184 100644 --- a/packages/cli/scripts/install.js +++ b/packages/cli/scripts/install.js @@ -5,7 +5,11 @@ const Listr = require('listr') const execa = require('execa') const spawn = require('child_process') const fs = require('fs') -const semverSort = require('semver-sort') +const semverSortDesc = require('semver/functions/rsort') +const semverSatisfies = require('semver/functions/satisfies') +const semverCoerce = require('semver/functions/coerce') +const semverInc = require('semver/functions/inc') +const merge = require('lodash/merge') module.exports = function (installationDir) { installationDir = installationDir || 'vue-storefront' @@ -17,9 +21,9 @@ module.exports = function (installationDir) { const options = { version: { - stable: 'Stable versions (recommended for production)', - rc: 'Release Candidates', - nightly: 'In development branches (could be unstable!)' + stable: 'Stable version (recommended for production)', + rc: 'Release Candidate', + nightly: 'In development branch (could be unstable!)' }, installation: { installer: 'Installer (MacOS/Linux only)', @@ -27,6 +31,24 @@ module.exports = function (installationDir) { } } + const themes = { + capybara: { + label: 'Capybara - based on Storefront UI', + branches: { + master: options.version.stable, + develop: options.version.nightly + }, + minVsfVersion: '^1.11.0' + }, + default: { + label: 'Default', + branches: { + master: options.version.stable + }, + minVsfVersion: '*' + } + } + const tasks = { installDeps: { title: 'Installing dependencies', @@ -38,23 +60,79 @@ module.exports = function (installationDir) { return execa.shell(`git clone --quiet --single-branch --branch ${answers.specificVersion} https://github.com/DivanteLtd/vue-storefront.git ${installationDir} && cd ${installationDir}/core/scripts && git remote rm origin`) } }, + cloneTheme: { + title: 'Copying Vue Storefront theme', + task: answers => execa.shell([ + `git clone --quiet --single-branch --branch ${answers.themeBranch} https://github.com/DivanteLtd/vsf-${answers.themeName}.git ${installationDir}/src/themes/${answers.themeName}`, + `cd ${installationDir}/src/themes/${answers.themeName}`, + `git remote rm origin` + ].join(' && ')), + skip: answers => { + if (fs.existsSync(`${installationDir}/src/themes/${answers.themeName}`)) { + return `Chosen theme already exists in Vue Storefront installation directory ./${installationDir}/src/themes/` + } + } + }, + configureTheme: { + title: 'Configuring Vue Storefront theme', + task: answers => { + const configurationFiles = ['local.config.js', 'local.json'] + const [themeLocalConfigJsPath, themeLocalJsonPath] = configurationFiles.map( + file => `${installationDir}/src/themes/${answers.themeName}/${file}` + ) + const vsfLocalJsonPath = `${installationDir}/config/local.json` + const vsfPackageJsonPath = `${installationDir}/package.json` + + try { + const isVsfVersionAsBranch = ['master', 'develop'].includes(answers.specificVersion) + const vsfVersionFromPackageJson = JSON.parse(fs.readFileSync(vsfPackageJsonPath)).version + const vsfVersion = isVsfVersionAsBranch + ? semverInc(vsfVersionFromPackageJson, 'minor') + : vsfVersionFromPackageJson + + const vsfLocalJson = fs.existsSync(vsfLocalJsonPath) + ? JSON.parse(fs.readFileSync(vsfLocalJsonPath)) + : {} + + const themeLocalJson = fs.existsSync(themeLocalConfigJsPath) + ? require(fs.realpathSync(themeLocalConfigJsPath))(vsfVersion) + : fs.existsSync(themeLocalJsonPath) + ? JSON.parse(fs.readFileSync(themeLocalJsonPath)) + : null + + if (themeLocalJson) { + fs.writeFileSync(vsfLocalJsonPath, JSON.stringify(merge(vsfLocalJson, themeLocalJson), null, 2)) + } + } catch (e) { + console.error(`Problem with parsing or merging configurations (${configurationFiles})\n`, e) + } + }, + skip: answers => { + const configurationFiles = ['local.config.js', 'local.json'] + const themePath = `${installationDir}/src/themes/${answers.themeName}` + + if (configurationFiles.every(file => !fs.existsSync(`${themePath}/${file}`))) { + return `Missing configuration file in theme folder (${configurationFiles}) - nothing to configure` + } + } + }, runInstaller: { title: 'Running installer', - task: () => spawn.execFileSync('yarn', ['installer'], {stdio: 'inherit', cwd: installationDir}) + task: () => spawn.execFileSync('yarn', ['installer'], { stdio: 'inherit', cwd: installationDir }) }, getStorefrontVersions: { - title: 'Check avalilable versions', + title: 'Check available versions', task: () => execa.stdout('git', ['ls-remote', '--tags', 'https://github.com/DivanteLtd/vue-storefront.git']).then(result => { - allTags = result.match(/refs\/tags\/v1.([0-9.]+)(-rc.[0-9])?/gm).map(tag => tag.replace('refs/tags/', '')) - allTags = semverSort.desc(allTags) - execa.stdout('git', ['ls-remote', '--heads', 'https://github.com/DivanteLtd/vue-storefront.git']).then(branches => { - let rcBranches = branches.match(/refs\/heads\/release\/v1.([0-9.]+)/gm).map(tag => tag.replace('refs/heads/', '')) - availableBranches = [...rcBranches, ...availableBranches] - }) + allTags = result.match(/refs\/tags\/v1.([0-9.]+)(-rc.[0-9])?/gm).map(tag => tag.replace('refs/tags/', '')) + allTags = semverSortDesc(allTags) + execa.stdout('git', ['ls-remote', '--heads', 'https://github.com/DivanteLtd/vue-storefront.git']).then(branches => { + let rcBranches = branches.match(/refs\/heads\/release\/v1.([0-9.x]+)/gm).map(tag => tag.replace('refs/heads/', '')) + availableBranches = [...rcBranches, ...availableBranches] + }) }).catch(e => { - console.error('Problem with checking versions', e) + console.error('Problem with checking versions\n', e) }) - }, + } } if (fs.existsSync(installationDir)) { @@ -81,10 +159,36 @@ module.exports = function (installationDir) { message: 'Select specific version', choices: function (answers) { if (answers.version === options.version.stable) return allTags.filter(tag => !tag.includes('rc')).slice(0, 10) - if (answers.version === options.version.rc) return allTags.filter(tag => tag.includes('rc')).slice(0,5) + if (answers.version === options.version.rc) return allTags.filter(tag => tag.includes('rc')).slice(0, 5) return availableBranches } }, + { + type: 'list', + name: 'themeName', + message: 'Select theme for Vue Storefront', + choices: answers => { + const isVsfVersionAsBranch = ['master', 'develop'].includes(answers.specificVersion) + const selectedVsfVersion = semverCoerce(answers.specificVersion) + + return Object.entries(themes) + .filter(([, themeConfig]) => isVsfVersionAsBranch || semverSatisfies(selectedVsfVersion, themeConfig.minVsfVersion, { includePrerelease: true })) + .map(([themeName, themeConfig]) => ({ + name: themeConfig.label, + value: themeName + })) + } + }, + { + type: 'list', + name: 'themeBranch', + message: 'Select theme version', + choices: answers => Object.entries(themes[answers.themeName].branches) + .map(([branchName, branchLabel]) => ({ + name: branchLabel, + value: branchName + })) + }, { type: 'list', name: 'installation', @@ -98,10 +202,12 @@ module.exports = function (installationDir) { .then(answers => { const taskQueue = [] taskQueue.push(tasks.cloneVersion) + taskQueue.push(tasks.cloneTheme) if (answers.installation === options.installation.installer) { taskQueue.push(tasks.installDeps) taskQueue.push(tasks.runInstaller) } + taskQueue.push(tasks.configureTheme) new Listr(taskQueue).run(answers) }) }) diff --git a/packages/cli/scripts/manual.js b/packages/cli/scripts/manual.js index 55b1b8c91..a08d94738 100644 --- a/packages/cli/scripts/manual.js +++ b/packages/cli/scripts/manual.js @@ -1,8 +1,8 @@ module.exports = function () { console.log('Usage: vsf [command] [options]\n') console.log('Options:') - console.log(' --help available commands') - console.log(' --version CLI version\n') + console.log(' --help | -h available commands') + console.log(' --version | -v CLI version\n') console.log('Commands:') console.log(' init [dir] setup new VS project') console.log(' init:module [name] generate vs module boilerplate') diff --git a/packages/cli/vue-storefront b/packages/cli/vue-storefront deleted file mode 160000 index 3af5a1a15..000000000 --- a/packages/cli/vue-storefront +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3af5a1a152489f7335aff49c9f1d29d92a45da2f diff --git a/src/modules/amp-renderer/router.ts b/src/modules/amp-renderer/router.ts index 0eea5d67e..ceda4742e 100644 --- a/src/modules/amp-renderer/router.ts +++ b/src/modules/amp-renderer/router.ts @@ -1,2 +1,12 @@ -import AmpThemeRouting from 'src/themes/default-amp/router' +import config from 'config' + +let AmpThemeRouting + +try { + const themeName = config.theme.replace('@vue-storefront/theme-', '') + AmpThemeRouting = require(`src/themes/${themeName}-amp/router`) +} catch (err) { + AmpThemeRouting = null +} + export default AmpThemeRouting diff --git a/src/modules/client.ts b/src/modules/client.ts index 192debde8..2e62808b5 100644 --- a/src/modules/client.ts +++ b/src/modules/client.ts @@ -10,12 +10,14 @@ import { UrlModule } from '@vue-storefront/core/modules/url' import { BreadcrumbsModule } from '@vue-storefront/core/modules/breadcrumbs' import { UserModule } from '@vue-storefront/core/modules/user' import { CmsModule } from '@vue-storefront/core/modules/cms' -import { GoogleTagManagerModule } from './google-tag-manager'; -import { AmpRendererModule } from './amp-renderer'; +// import { GoogleTagManagerModule } from './google-tag-manager'; +// import { AmpRendererModule } from './amp-renderer'; import { PaymentBackendMethodsModule } from './payment-backend-methods' import { PaymentCashOnDeliveryModule } from './payment-cash-on-delivery' import { NewsletterModule } from '@vue-storefront/core/modules/newsletter' +import { InitialResourcesModule } from '@vue-storefront/core/modules/initial-resources' +// import { DeviceModule } from './device/index'; import { registerModule } from '@vue-storefront/core/lib/modules' // TODO:distributed across proper pages BEFORE 1.11 @@ -32,10 +34,12 @@ export function registerClientModules () { registerModule(CatalogNextModule) registerModule(CompareModule) registerModule(BreadcrumbsModule) - registerModule(GoogleTagManagerModule) - registerModule(AmpRendererModule) + // registerModule(GoogleTagManagerModule) + // registerModule(AmpRendererModule) registerModule(CmsModule) registerModule(NewsletterModule) + registerModule(InitialResourcesModule) + // registerModule(DeviceModule) } // Deprecated API, will be removed in 2.0 diff --git a/src/modules/device/README.md b/src/modules/device/README.md new file mode 100644 index 000000000..efb1b5d01 --- /dev/null +++ b/src/modules/device/README.md @@ -0,0 +1,32 @@ +### Device + +Based on: https://github.com/nuxt-community/device-module + +It provides as some logic helpers based on UserAgent. List of tests: +``` +isMobile, +isMobileOrTablet, +isTablet, +isDesktop, +isDesktopOrTablet, +isIos, +isWindows, +isMacOS +``` + +They are accessible by, e.g:: +``` +this.$device.isMobile +``` +Or in asyncData by (you need to import Vue): +``` +Vue.prototype.$device.isMobile +``` + +We could totally disable this feature by setting `config.device.appendToInstance` to false and the small library will not by imported. + +In addition when we are using installer script. I've added multiselect so we could pick which tests we want to have. + +I've tested it with `curl -A "some user agent" http://localhost:3000 and it worked. + +That's obvious we should use media queries whenever we can. However, sometimes we have more advanced structure and we are ending with hidden useless vue instance. diff --git a/src/modules/device/index.ts b/src/modules/device/index.ts new file mode 100644 index 000000000..0ca15e1b4 --- /dev/null +++ b/src/modules/device/index.ts @@ -0,0 +1,34 @@ +import { isServer } from '@vue-storefront/core/helpers/index'; +import { StorefrontModule } from '@vue-storefront/core/lib/modules'; +import Vue from 'vue' + +const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.39 Safari/537.36' + +export const DeviceModule: StorefrontModule = async function ({ app, appConfig }) { + let headersOrUserAgent + if (isServer) { + headersOrUserAgent = Vue.prototype.$ssrRequestContext.userAgent || DEFAULT_USER_AGENT + } else { + headersOrUserAgent = window.navigator.userAgent || DEFAULT_USER_AGENT + } + + if (appConfig.device && appConfig.device.appendToInstance && appConfig.device.tests && appConfig.device.tests.length) { + const deviceLibrary: any = await import(/* webpackChunkName: "device" */ './logic') + let userAgent = typeof headersOrUserAgent === 'string' + ? headersOrUserAgent + : headersOrUserAgent['user-agent'] + + Vue.prototype.$device = deviceLibrary.default(userAgent, appConfig.device.tests) + if (userAgent === 'Amazon CloudFront') { + if (headersOrUserAgent['cloudfront-is-mobile-viewer'] === 'true') { + Vue.prototype.$device.isMobile = true + Vue.prototype.$device.isMobileOrTablet = true + } + if (headersOrUserAgent['cloudfront-is-tablet-viewer'] === 'true') { + Vue.prototype.$device.isMobile = false + Vue.prototype.$device.isMobileOrTablet = true + } + } + (app as any).device = Vue.prototype.$device + } +} diff --git a/src/modules/device/logic/index.ts b/src/modules/device/logic/index.ts new file mode 100644 index 000000000..14c4281cb --- /dev/null +++ b/src/modules/device/logic/index.ts @@ -0,0 +1,70 @@ +// these regular expressions are borrowed from below page. +// https://stackoverflow.com/questions/11381673/detecting-a-mobile-browser + +// eslint-disable-next-line +const REGEX_MOBILE1 = /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i + +// eslint-disable-next-line +const REGEX_MOBILE2 = /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i + +// eslint-disable-next-line +const REGEX_MOBILE_OR_TABLET1 = /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i +// eslint-disable-next-line +const REGEX_MOBILE_OR_TABLET2 = /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i + +const testers = { + isMobile (a) { + return REGEX_MOBILE1.test(a) || REGEX_MOBILE2.test(a.substr(0, 4)) + }, + + isMobileOrTablet (a) { + return REGEX_MOBILE_OR_TABLET1.test(a) || REGEX_MOBILE_OR_TABLET2.test(a.substr(0, 4)) + }, + + isIos (a) { + return /iPad|iPhone|iPod/.test(a) + }, + + isWindows (a) { + return /Windows/.test(a) + }, + + isMacOS (a) { + return /Mac OS X/.test(a) + } +} + +interface DeviceTests { + isMobile?: boolean, + isMobileOrTablet?: boolean, + isTablet?: boolean, + isDesktop?: boolean, + isDesktopOrTablet?: boolean, + isIos?: boolean, + isWindows?: boolean, + isMacOS?: boolean +} + +export default (userAgent: string, tests: string[]): DeviceTests => { + const deviceTests: DeviceTests = {} + + for (let test of tests) { + if (testers[test]) { + deviceTests[test] = testers[test](userAgent) + } else { + switch (test) { + case 'isTablet': + deviceTests[test] = !testers['isMobile'](userAgent) && !testers['isMobileOrTablet'](userAgent); + break; + case 'isDesktop': + deviceTests[test] = !testers['isMobileOrTablet'](userAgent) + break; + case 'isDesktopOrTablet': + deviceTests[test] = !testers['isMobile'](userAgent) + break; + } + } + } + + return deviceTests +} diff --git a/src/modules/fastly/README.md b/src/modules/fastly/README.md new file mode 100644 index 000000000..50293a731 --- /dev/null +++ b/src/modules/fastly/README.md @@ -0,0 +1,24 @@ +# VSF Cache Fastly +This module extends default caching docs/guide/basics/ssr-cache.md to allow using fastly as cache provider. + +## How to install +Add to config: +```json +"fastly": { + "enabled": true, + "serviceId": "xyz", // (https://docs.fastly.com/en/guides/finding-and-managing-your-account-info#finding-your-service-id) + "token": "xyz" // fastly api token (https://docs.fastly.com/api/auth#tokens) +} +``` + +Change those values in `server` section: +```json +"useOutputCacheTagging": true, +"useOutputCache": true +``` + +## How to purge cache? +Open: +``` +http://localhost:3000/invalidate?key=aeSu7aip&tag=home +``` diff --git a/src/modules/fastly/server.ts b/src/modules/fastly/server.ts new file mode 100644 index 000000000..474c5525a --- /dev/null +++ b/src/modules/fastly/server.ts @@ -0,0 +1,39 @@ +import { serverHooks } from '@vue-storefront/core/server/hooks' +import fetch from 'isomorphic-fetch' +import config from 'config' + +const chunk = require('lodash/chunk') + +serverHooks.beforeOutputRenderedResponse(({ output, res, context }) => { + if (!config.get('fastly.enabled')) { + return output + } + + const tagsArray = Array.from(context.output.cacheTags) + const cacheTags = tagsArray.join(' ') + res.setHeader('Surrogate-Key', cacheTags) + + return output +}) + +serverHooks.beforeCacheInvalidated(async ({ tags }) => { + if (!config.get('fastly.enabled') || !config.get('server.useOutputCache') || !config.get('server.useOutputCacheTagging')) { + return + } + + console.log('Invalidating Fastly Surrogate-Key') + const tagsChunks = chunk(tags.filter((tag) => + config.server.availableCacheTags.indexOf(tag) >= 0 || + config.server.availableCacheTags.find(t => tag.indexOf(t) === 0) + ), 256) // we can send maximum 256 keys per request, more info https://docs.fastly.com/api/purge#purge_db35b293f8a724717fcf25628d713583 + + for (const tagsChunk of tagsChunks) { + const response = await fetch(`https://api.fastly.com/service/${config.get('fastly.serviceId')}/purge`, { + method: 'POST', + headers: { 'Fastly-Key': config.get('fastly.token') }, + body: JSON.stringify({ surrogate_keys: tagsChunk }) + }) + const text = await response.text() + console.log(text) + } +}) diff --git a/src/modules/instant-checkout/components/InstantCheckout.vue b/src/modules/instant-checkout/components/InstantCheckout.vue index 461ebb499..084316296 100644 --- a/src/modules/instant-checkout/components/InstantCheckout.vue +++ b/src/modules/instant-checkout/components/InstantCheckout.vue @@ -14,8 +14,7 @@ import rootStore from '@vue-storefront/core/store' import { currentStoreView } from '@vue-storefront/core/lib/multistore' import { registerModule } from '@vue-storefront/core/lib/modules' import { OrderModule } from '@vue-storefront/core/modules/order' - -const storeView = currentStoreView() +import { Logger } from '@vue-storefront/core/lib/logger' export default { name: 'InstantCheckoutButton', @@ -25,7 +24,7 @@ export default { data () { return { supported: false, - country: rootStore.state.checkout.shippingDetails.country ? rootStore.state.checkout.shippingDetails.country : storeView.tax.defaultCountry, + country: rootStore.state.checkout.shippingDetails.country ? rootStore.state.checkout.shippingDetails.country : currentStoreView().tax.defaultCountry, paymentMethods: [ { supportedMethods: ['basic-card'], @@ -56,7 +55,7 @@ export default { bucket.push({ label: product.name, amount: { - currency: storeView.i18n.currencyCode, + currency: currentStoreView().i18n.currencyCode, value: this.getProductPrice(product) } }) @@ -67,7 +66,7 @@ export default { if (this.selectedShippingOption.length > 0) { bucket.push({ label: i18n.t('Shipping'), - amount: { currency: storeView.i18n.currencyCode, value: this.selectedShippingOption[0].amount.value } + amount: { currency: currentStoreView().i18n.currencyCode, value: this.selectedShippingOption[0].amount.value } }) } @@ -75,13 +74,11 @@ export default { } // If synchronization is eanbled get shipping and discount values from Magento - const shipping = this.platformTotal.filter(segment => { - return segment.code === 'shipping' - }) + const shipping = this.platformTotal.filter(segment => segment.code === 'shipping' && segment.value) if (shipping.length > 0) { bucket.push({ label: shipping[0].title, - amount: { currency: storeView.i18n.currencyCode, value: shipping[0].value } + amount: { currency: currentStoreView().i18n.currencyCode, value: shipping[0].value } }) } @@ -107,7 +104,7 @@ export default { return { label: i18n.t('Grand total'), - amount: { currency: storeView.i18n.currencyCode, value: subtotal } + amount: { currency: currentStoreView().i18n.currencyCode, value: subtotal } } } @@ -118,7 +115,7 @@ export default { if (total.length > 0) { return { label: total[0].title, - amount: { currency: storeView.i18n.currencyCode, value: total[0].value } + amount: { currency: currentStoreView().i18n.currencyCode, value: total[0].value } } } @@ -155,7 +152,7 @@ export default { }) }) .catch(e => { - console.log(e) + Logger.log(e)() }) }, shippingOptionChange (event) { @@ -181,7 +178,7 @@ export default { total: this.total }) }).catch(e => { - console.error(e) + Logger.error(e)() reject(e) }) }) @@ -211,7 +208,7 @@ export default { total: this.total }) }).catch(e => { - console.error(e) + Logger.error(e)() reject(e) }) }) @@ -231,14 +228,14 @@ export default { label: method.method_title, selected: setDefault ? this.$store.getters['checkout/getShippingMethods'][0].method_code === method.method_code : false, amount: { - currency: storeView.i18n.currencyCode, + currency: currentStoreView().i18n.currencyCode, value: method.price_incl_tax } }) }) resolve() }).catch(e => { - console.error(e) + Logger.error(e)() reject(e) }) }) diff --git a/src/modules/vsf-cache-nginx b/src/modules/vsf-cache-nginx new file mode 160000 index 000000000..c2c07879c --- /dev/null +++ b/src/modules/vsf-cache-nginx @@ -0,0 +1 @@ +Subproject commit c2c07879ca261b88c7cc76c45eaa49519bfcfc87 diff --git a/src/modules/vsf-cache-varnish b/src/modules/vsf-cache-varnish new file mode 160000 index 000000000..09cc48f65 --- /dev/null +++ b/src/modules/vsf-cache-varnish @@ -0,0 +1 @@ +Subproject commit 09cc48f65ee174dc39eec809cb5f816fd3f039fa diff --git a/src/search/adapter/.gitkeep b/src/search/adapter/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/core/lib/search/adapter/graphql/gqlQuery.js b/src/search/adapter/graphql/gqlQuery.js similarity index 100% rename from core/lib/search/adapter/graphql/gqlQuery.js rename to src/search/adapter/graphql/gqlQuery.js diff --git a/core/lib/search/adapter/graphql/processor/processType.ts b/src/search/adapter/graphql/processor/processType.ts similarity index 100% rename from core/lib/search/adapter/graphql/processor/processType.ts rename to src/search/adapter/graphql/processor/processType.ts diff --git a/core/lib/search/adapter/graphql/queries/categories.gql b/src/search/adapter/graphql/queries/categories.gql similarity index 100% rename from core/lib/search/adapter/graphql/queries/categories.gql rename to src/search/adapter/graphql/queries/categories.gql diff --git a/core/lib/search/adapter/graphql/queries/cmsBlock.gql b/src/search/adapter/graphql/queries/cmsBlock.gql similarity index 100% rename from core/lib/search/adapter/graphql/queries/cmsBlock.gql rename to src/search/adapter/graphql/queries/cmsBlock.gql diff --git a/core/lib/search/adapter/graphql/queries/cmsHierarchy.gql b/src/search/adapter/graphql/queries/cmsHierarchy.gql similarity index 100% rename from core/lib/search/adapter/graphql/queries/cmsHierarchy.gql rename to src/search/adapter/graphql/queries/cmsHierarchy.gql diff --git a/core/lib/search/adapter/graphql/queries/cmsPage.gql b/src/search/adapter/graphql/queries/cmsPage.gql similarity index 100% rename from core/lib/search/adapter/graphql/queries/cmsPage.gql rename to src/search/adapter/graphql/queries/cmsPage.gql diff --git a/core/lib/search/adapter/graphql/queries/customAttributeMetadata.gql b/src/search/adapter/graphql/queries/customAttributeMetadata.gql similarity index 100% rename from core/lib/search/adapter/graphql/queries/customAttributeMetadata.gql rename to src/search/adapter/graphql/queries/customAttributeMetadata.gql diff --git a/core/lib/search/adapter/graphql/queries/products.gql b/src/search/adapter/graphql/queries/products.gql similarity index 100% rename from core/lib/search/adapter/graphql/queries/products.gql rename to src/search/adapter/graphql/queries/products.gql diff --git a/core/lib/search/adapter/graphql/queries/reviews.gql b/src/search/adapter/graphql/queries/reviews.gql similarity index 100% rename from core/lib/search/adapter/graphql/queries/reviews.gql rename to src/search/adapter/graphql/queries/reviews.gql diff --git a/core/lib/search/adapter/graphql/queries/taxrule.gql b/src/search/adapter/graphql/queries/taxrule.gql similarity index 100% rename from core/lib/search/adapter/graphql/queries/taxrule.gql rename to src/search/adapter/graphql/queries/taxrule.gql diff --git a/core/lib/search/adapter/graphql/searchAdapter.ts b/src/search/adapter/graphql/searchAdapter.ts similarity index 88% rename from core/lib/search/adapter/graphql/searchAdapter.ts rename to src/search/adapter/graphql/searchAdapter.ts index 56dfb495a..86640a1bb 100644 --- a/core/lib/search/adapter/graphql/searchAdapter.ts +++ b/src/search/adapter/graphql/searchAdapter.ts @@ -1,9 +1,11 @@ import { prepareQueryVars } from './gqlQuery' -import { currentStoreView, prepareStoreView } from '../../../multistore' +import { currentStoreView, prepareStoreView } from '@vue-storefront/core/lib/multistore' import fetch from 'isomorphic-fetch' import { processESResponseType, processProductsType, processCmsType } from './processor/processType' -import SearchQuery from '../../searchQuery' +import { SearchQuery } from 'storefront-query-builder' import config from 'config' +import { isServer } from '@vue-storefront/core/helpers' +import getApiEndpointUrl from '@vue-storefront/core/helpers/getApiEndpointUrl'; export class SearchAdapter { public entities: any @@ -41,10 +43,13 @@ export class SearchAdapter { // define GraphQL url from searchAdapter entity or use default graphQl host with storeCode param let urlGql = '' - if (this.entities[Request.type].url) { - urlGql = this.entities[Request.type].url + if (getApiEndpointUrl(this.entities[Request.type], 'url')) { + urlGql = getApiEndpointUrl(this.entities[Request.type], 'url') } else { - urlGql = config.server.protocol + '://' + config.graphql.host + ':' + config.graphql.port + '/graphql' + const serverProtocol = isServer ? getApiEndpointUrl(config.server, 'protocol') : config.server.protocol + const host = isServer ? getApiEndpointUrl(config.graphql, 'host') : config.graphql.host + const port = isServer ? getApiEndpointUrl(config.graphql, 'port') : config.graphql.port + urlGql = serverProtocol + '://' + host + ':' + port + '/graphql' const urlStoreCode = (storeView.storeCode !== '') ? encodeURIComponent(storeView.storeCode) + '/' : '' urlGql = urlGql + '/' + urlStoreCode } @@ -73,7 +78,7 @@ export class SearchAdapter { * @param {function} resultProcessor process results of response * @return {Object} */ - public registerEntityType (entityType, { url = '', gql, queryProcessor, resultProcessor }) { + public registerEntityType (entityType, { url = '', url_ssr = '', gql, queryProcessor, resultProcessor }) { this.entities[entityType] = { query: require(`${gql}`), queryProcessor: queryProcessor, @@ -82,6 +87,9 @@ export class SearchAdapter { if (url !== '') { this.entities[entityType]['url'] = url } + if (url_ssr !== '') { + this.entities[entityType]['url_ssr'] = url_ssr + } return this } @@ -93,7 +101,7 @@ export class SearchAdapter { * @param {function} resultProcessor process results of response * @return {Object} */ - public registerEntityTypeByQuery (entityType, { url = '', query, queryProcessor, resultProcessor }) { + public registerEntityTypeByQuery (entityType, { url = '', url_ssr = '', query, queryProcessor, resultProcessor }) { this.entities[entityType] = { query: query, queryProcessor: queryProcessor, @@ -102,6 +110,9 @@ export class SearchAdapter { if (url !== '') { this.entities[entityType]['url'] = url } + if (url_ssr !== '') { + this.entities[entityType]['url_ssr'] = url_ssr + } return this } diff --git a/src/themes/default-amp/components/core/Header.vue b/src/themes/default-amp/components/core/Header.vue deleted file mode 100755 index 62202c98e..000000000 --- a/src/themes/default-amp/components/core/Header.vue +++ /dev/null @@ -1,173 +0,0 @@ - - - - - diff --git a/src/themes/default-amp/components/core/ProductListing.vue b/src/themes/default-amp/components/core/ProductListing.vue deleted file mode 100755 index c6fe602c6..000000000 --- a/src/themes/default-amp/components/core/ProductListing.vue +++ /dev/null @@ -1,45 +0,0 @@ - - - diff --git a/src/themes/default-amp/components/core/ProductTile.vue b/src/themes/default-amp/components/core/ProductTile.vue deleted file mode 100755 index d2daa0a3b..000000000 --- a/src/themes/default-amp/components/core/ProductTile.vue +++ /dev/null @@ -1,191 +0,0 @@ - - - - - diff --git a/src/themes/default-amp/css/_README.md b/src/themes/default-amp/css/_README.md deleted file mode 100755 index 8ff35b826..000000000 --- a/src/themes/default-amp/css/_README.md +++ /dev/null @@ -1,4 +0,0 @@ -# Documentation -Please read the docs before working with stylesheets. - -[Working with stylesheets](https://github.com/DivanteLtd/vue-storefront/blob/master/doc/Working%20with%20stylesheets%20(CSS).md) - how to use and extend stylesheets in Vue Storefront. \ No newline at end of file diff --git a/src/themes/default-amp/css/animations/_animations.scss b/src/themes/default-amp/css/animations/_animations.scss deleted file mode 100755 index a24afcaa1..000000000 --- a/src/themes/default-amp/css/animations/_animations.scss +++ /dev/null @@ -1,27 +0,0 @@ -@keyframes images-lazy-loading { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -[lazy="loaded"] { - animation: images-lazy-loading; - animation-duration: 0.3s; -} - -@keyframes ripple-effect { - 0% { - transform: translate(-50%, -50%) scale(0); - } - 20% { - transform: translate(-50%, -50%) scale(1); - } - 100% { - opacity: 0; - transform: translate(-50%, -50%) scale(1); - } -} diff --git a/src/themes/default-amp/css/animations/_transitions.scss b/src/themes/default-amp/css/animations/_transitions.scss deleted file mode 100755 index 363d9f0b6..000000000 --- a/src/themes/default-amp/css/animations/_transitions.scss +++ /dev/null @@ -1,38 +0,0 @@ -$motion-main: cubic-bezier(0.54, 0.02, 0.35, 0.88); -$duration-main: .3s; - -.fade-enter-active, -.fade-leave-active { - transition: opacity $duration-main; -} - -.fade-enter, -.fade-leave-to { - opacity: 0 -} - -.fade-in { - &-down-enter-active, - &-down-leave-active, - &-up-enter-active, - &-up-leave-active { - transition: all $duration-main $motion-main; - } - - &-down-enter, - &-down-leave-to, - &-up-enter, - &-up-leave-to { - opacity: 0; - } - - &-down-enter, - &-down-leave-to { - transform: translateY(-100%); - } - - &-up-enter, - &-up-leave-to { - transform: translateY(100%); - } -} diff --git a/src/themes/default-amp/css/base/_base.scss b/src/themes/default-amp/css/base/_base.scss deleted file mode 100755 index 3ee23a4b0..000000000 --- a/src/themes/default-amp/css/base/_base.scss +++ /dev/null @@ -1,21 +0,0 @@ -html, -body { - margin: 0; - padding: 0; -} - -a { - color: inherit; - position: relative; - text-decoration: none; - -webkit-tap-highlight-color: rgba(0,0,0,0); - -webkit-tap-highlight-color: transparent; -} - -#app { - overflow-x: hidden; -} - -#viewport { - overflow-x: hidden; -} diff --git a/src/themes/default-amp/css/base/_color.scss b/src/themes/default-amp/css/base/_color.scss deleted file mode 100755 index 401f7cb5f..000000000 --- a/src/themes/default-amp/css/base/_color.scss +++ /dev/null @@ -1,16 +0,0 @@ -// Colors / backgrounds -// ======================= -// Generate colors selectors -// .cl-{#name-of-color} -@include color-selectors(map_merge($colors, $colors-theme), 'cl', 'color'); - -// .cl-{#palette}-{#name-of-color} -@include color-selectors($colors-background, 'cl-bg', 'color'); -@include color-selectors($colors-border, 'cl-brdr', 'color'); - -// Generate background-color selectors -// .bg-cl-{#name-of-color} -@include color-selectors(map_merge($colors, $colors-background), 'bg-cl', 'background-color'); - -// .bg-cl-#{#palette}-{#name-of-color} -@include color-selectors($colors-theme, 'bg-cl-th', 'background-color'); diff --git a/src/themes/default-amp/css/base/_flexbox-grid.css b/src/themes/default-amp/css/base/_flexbox-grid.css deleted file mode 100755 index 2f502c950..000000000 --- a/src/themes/default-amp/css/base/_flexbox-grid.css +++ /dev/null @@ -1 +0,0 @@ -.container,.container-fluid{margin-right:auto;margin-left:auto}.container-fluid{padding-right:2rem;padding-left:2rem}.row{box-sizing:border-box;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-flex:0;-ms-flex:0 1 auto;flex:0 1 auto;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-.5rem;margin-left:-.5rem}.row.reverse{-webkit-box-orient:horizontal;-webkit-box-direction:reverse;-ms-flex-direction:row-reverse;flex-direction:row-reverse}.col.reverse{-webkit-box-orient:vertical;-webkit-box-direction:reverse;-ms-flex-direction:column-reverse;flex-direction:column-reverse}.col-xs,.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9,.col-xs-offset-0,.col-xs-offset-1,.col-xs-offset-10,.col-xs-offset-11,.col-xs-offset-12,.col-xs-offset-2,.col-xs-offset-3,.col-xs-offset-4,.col-xs-offset-5,.col-xs-offset-6,.col-xs-offset-7,.col-xs-offset-8,.col-xs-offset-9{box-sizing:border-box;-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;padding-right:.5rem;padding-left:.5rem}.col-xs{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;-ms-flex-preferred-size:0;flex-basis:0;max-width:100%}.col-xs-1{-ms-flex-preferred-size:8.33333333%;flex-basis:8.33333333%;max-width:8.33333333%}.col-xs-2{-ms-flex-preferred-size:16.66666667%;flex-basis:16.66666667%;max-width:16.66666667%}.col-xs-3{-ms-flex-preferred-size:25%;flex-basis:25%;max-width:25%}.col-xs-4{-ms-flex-preferred-size:33.33333333%;flex-basis:33.33333333%;max-width:33.33333333%}.col-xs-5{-ms-flex-preferred-size:41.66666667%;flex-basis:41.66666667%;max-width:41.66666667%}.col-xs-6{-ms-flex-preferred-size:50%;flex-basis:50%;max-width:50%}.col-xs-7{-ms-flex-preferred-size:58.33333333%;flex-basis:58.33333333%;max-width:58.33333333%}.col-xs-8{-ms-flex-preferred-size:66.66666667%;flex-basis:66.66666667%;max-width:66.66666667%}.col-xs-9{-ms-flex-preferred-size:75%;flex-basis:75%;max-width:75%}.col-xs-10{-ms-flex-preferred-size:83.33333333%;flex-basis:83.33333333%;max-width:83.33333333%}.col-xs-11{-ms-flex-preferred-size:91.66666667%;flex-basis:91.66666667%;max-width:91.66666667%}.col-xs-12{-ms-flex-preferred-size:100%;flex-basis:100%;max-width:100%}.col-xs-offset-0{margin-left:0}.col-xs-offset-1{margin-left:8.33333333%}.col-xs-offset-2{margin-left:16.66666667%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-4{margin-left:33.33333333%}.col-xs-offset-5{margin-left:41.66666667%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-7{margin-left:58.33333333%}.col-xs-offset-8{margin-left:66.66666667%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-10{margin-left:83.33333333%}.col-xs-offset-11{margin-left:91.66666667%}.start-xs{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start;text-align:start}.center-xs{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;text-align:center}.end-xs{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end;text-align:end}.top-xs{-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.middle-xs{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.bottom-xs{-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end}.around-xs{-ms-flex-pack:distribute;justify-content:space-around}.between-xs{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.first-xs{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.last-xs{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}@media only screen and (min-width:48em){.container{width:49rem}.col-sm,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-offset-0,.col-sm-offset-1,.col-sm-offset-10,.col-sm-offset-11,.col-sm-offset-12,.col-sm-offset-2,.col-sm-offset-3,.col-sm-offset-4,.col-sm-offset-5,.col-sm-offset-6,.col-sm-offset-7,.col-sm-offset-8,.col-sm-offset-9{box-sizing:border-box;-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;padding-right:.5rem;padding-left:.5rem}.col-sm{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;-ms-flex-preferred-size:0;flex-basis:0;max-width:100%}.col-sm-1{-ms-flex-preferred-size:8.33333333%;flex-basis:8.33333333%;max-width:8.33333333%}.col-sm-2{-ms-flex-preferred-size:16.66666667%;flex-basis:16.66666667%;max-width:16.66666667%}.col-sm-3{-ms-flex-preferred-size:25%;flex-basis:25%;max-width:25%}.col-sm-4{-ms-flex-preferred-size:33.33333333%;flex-basis:33.33333333%;max-width:33.33333333%}.col-sm-5{-ms-flex-preferred-size:41.66666667%;flex-basis:41.66666667%;max-width:41.66666667%}.col-sm-6{-ms-flex-preferred-size:50%;flex-basis:50%;max-width:50%}.col-sm-7{-ms-flex-preferred-size:58.33333333%;flex-basis:58.33333333%;max-width:58.33333333%}.col-sm-8{-ms-flex-preferred-size:66.66666667%;flex-basis:66.66666667%;max-width:66.66666667%}.col-sm-9{-ms-flex-preferred-size:75%;flex-basis:75%;max-width:75%}.col-sm-10{-ms-flex-preferred-size:83.33333333%;flex-basis:83.33333333%;max-width:83.33333333%}.col-sm-11{-ms-flex-preferred-size:91.66666667%;flex-basis:91.66666667%;max-width:91.66666667%}.col-sm-12{-ms-flex-preferred-size:100%;flex-basis:100%;max-width:100%}.col-sm-offset-0{margin-left:0}.col-sm-offset-1{margin-left:8.33333333%}.col-sm-offset-2{margin-left:16.66666667%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-4{margin-left:33.33333333%}.col-sm-offset-5{margin-left:41.66666667%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-7{margin-left:58.33333333%}.col-sm-offset-8{margin-left:66.66666667%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-10{margin-left:83.33333333%}.col-sm-offset-11{margin-left:91.66666667%}.start-sm{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start;text-align:start}.center-sm{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;text-align:center}.end-sm{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end;text-align:end}.top-sm{-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.middle-sm{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.bottom-sm{-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end}.around-sm{-ms-flex-pack:distribute;justify-content:space-around}.between-sm{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.first-sm{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.last-sm{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}}@media only screen and (min-width:64em){.container{width:65rem}.col-md,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-offset-0,.col-md-offset-1,.col-md-offset-10,.col-md-offset-11,.col-md-offset-12,.col-md-offset-2,.col-md-offset-3,.col-md-offset-4,.col-md-offset-5,.col-md-offset-6,.col-md-offset-7,.col-md-offset-8,.col-md-offset-9{box-sizing:border-box;-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;padding-right:.5rem;padding-left:.5rem}.col-md{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;-ms-flex-preferred-size:0;flex-basis:0;max-width:100%}.col-md-1{-ms-flex-preferred-size:8.33333333%;flex-basis:8.33333333%;max-width:8.33333333%}.col-md-2{-ms-flex-preferred-size:16.66666667%;flex-basis:16.66666667%;max-width:16.66666667%}.col-md-3{-ms-flex-preferred-size:25%;flex-basis:25%;max-width:25%}.col-md-4{-ms-flex-preferred-size:33.33333333%;flex-basis:33.33333333%;max-width:33.33333333%}.col-md-5{-ms-flex-preferred-size:41.66666667%;flex-basis:41.66666667%;max-width:41.66666667%}.col-md-6{-ms-flex-preferred-size:50%;flex-basis:50%;max-width:50%}.col-md-7{-ms-flex-preferred-size:58.33333333%;flex-basis:58.33333333%;max-width:58.33333333%}.col-md-8{-ms-flex-preferred-size:66.66666667%;flex-basis:66.66666667%;max-width:66.66666667%}.col-md-9{-ms-flex-preferred-size:75%;flex-basis:75%;max-width:75%}.col-md-10{-ms-flex-preferred-size:83.33333333%;flex-basis:83.33333333%;max-width:83.33333333%}.col-md-11{-ms-flex-preferred-size:91.66666667%;flex-basis:91.66666667%;max-width:91.66666667%}.col-md-12{-ms-flex-preferred-size:100%;flex-basis:100%;max-width:100%}.col-md-offset-0{margin-left:0}.col-md-offset-1{margin-left:8.33333333%}.col-md-offset-2{margin-left:16.66666667%}.col-md-offset-3{margin-left:25%}.col-md-offset-4{margin-left:33.33333333%}.col-md-offset-5{margin-left:41.66666667%}.col-md-offset-6{margin-left:50%}.col-md-offset-7{margin-left:58.33333333%}.col-md-offset-8{margin-left:66.66666667%}.col-md-offset-9{margin-left:75%}.col-md-offset-10{margin-left:83.33333333%}.col-md-offset-11{margin-left:91.66666667%}.start-md{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start;text-align:start}.center-md{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;text-align:center}.end-md{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end;text-align:end}.top-md{-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.middle-md{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.bottom-md{-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end}.around-md{-ms-flex-pack:distribute;justify-content:space-around}.between-md{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.first-md{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.last-md{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}}@media only screen and (min-width:75em){.container{width:76rem}.col-lg,.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-offset-0,.col-lg-offset-1,.col-lg-offset-10,.col-lg-offset-11,.col-lg-offset-12,.col-lg-offset-2,.col-lg-offset-3,.col-lg-offset-4,.col-lg-offset-5,.col-lg-offset-6,.col-lg-offset-7,.col-lg-offset-8,.col-lg-offset-9{box-sizing:border-box;-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;padding-right:.5rem;padding-left:.5rem}.col-lg{-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;-ms-flex-preferred-size:0;flex-basis:0;max-width:100%}.col-lg-1{-ms-flex-preferred-size:8.33333333%;flex-basis:8.33333333%;max-width:8.33333333%}.col-lg-2{-ms-flex-preferred-size:16.66666667%;flex-basis:16.66666667%;max-width:16.66666667%}.col-lg-3{-ms-flex-preferred-size:25%;flex-basis:25%;max-width:25%}.col-lg-4{-ms-flex-preferred-size:33.33333333%;flex-basis:33.33333333%;max-width:33.33333333%}.col-lg-5{-ms-flex-preferred-size:41.66666667%;flex-basis:41.66666667%;max-width:41.66666667%}.col-lg-6{-ms-flex-preferred-size:50%;flex-basis:50%;max-width:50%}.col-lg-7{-ms-flex-preferred-size:58.33333333%;flex-basis:58.33333333%;max-width:58.33333333%}.col-lg-8{-ms-flex-preferred-size:66.66666667%;flex-basis:66.66666667%;max-width:66.66666667%}.col-lg-9{-ms-flex-preferred-size:75%;flex-basis:75%;max-width:75%}.col-lg-10{-ms-flex-preferred-size:83.33333333%;flex-basis:83.33333333%;max-width:83.33333333%}.col-lg-11{-ms-flex-preferred-size:91.66666667%;flex-basis:91.66666667%;max-width:91.66666667%}.col-lg-12{-ms-flex-preferred-size:100%;flex-basis:100%;max-width:100%}.col-lg-offset-0{margin-left:0}.col-lg-offset-1{margin-left:8.33333333%}.col-lg-offset-2{margin-left:16.66666667%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-4{margin-left:33.33333333%}.col-lg-offset-5{margin-left:41.66666667%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-7{margin-left:58.33333333%}.col-lg-offset-8{margin-left:66.66666667%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-10{margin-left:83.33333333%}.col-lg-offset-11{margin-left:91.66666667%}.start-lg{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start;text-align:start}.center-lg{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;text-align:center}.end-lg{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end;text-align:end}.top-lg{-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.middle-lg{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.bottom-lg{-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end}.around-lg{-ms-flex-pack:distribute;justify-content:space-around}.between-lg{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}.first-lg{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.last-lg{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}} \ No newline at end of file diff --git a/src/themes/default-amp/css/base/_global_vars.scss b/src/themes/default-amp/css/base/_global_vars.scss deleted file mode 100755 index 4d9f11ec5..000000000 --- a/src/themes/default-amp/css/base/_global_vars.scss +++ /dev/null @@ -1,7 +0,0 @@ -// @todo move to separate file -$z-index: ( - overlay: 3, - modal: 10, - notification: 1000, - loader: 1001 -) diff --git a/src/themes/default-amp/css/base/_icons.scss b/src/themes/default-amp/css/base/_icons.scss deleted file mode 100755 index a9af0a28f..000000000 --- a/src/themes/default-amp/css/base/_icons.scss +++ /dev/null @@ -1,26 +0,0 @@ -// @font-face { -// font-family: 'Material Icons'; -// font-style: normal; -// font-weight: 400; -// src: local('Material Icons'), -// local('MaterialIcons-Regular'), -// url('../assets/fonts/MaterialIcons-Regular.woff2') format('woff2'), -// url('../assets/fonts/MaterialIcons-Regular.woff') format('woff'), -// ; -// } - -// .material-icons { -// font-family: 'Material Icons'; -// font-weight: normal; -// font-style: normal; -// font-size: 24px; -// line-height: 1; -// letter-spacing: normal; -// text-transform: none; -// display: inline-block; -// white-space: nowrap; -// word-wrap: normal; -// direction: ltr; -// -webkit-font-feature-settings: 'liga'; -// -webkit-font-smoothing: antialiased; -// } \ No newline at end of file diff --git a/src/themes/default-amp/css/base/_text.scss b/src/themes/default-amp/css/base/_text.scss deleted file mode 100755 index 941cf239a..000000000 --- a/src/themes/default-amp/css/base/_text.scss +++ /dev/null @@ -1,114 +0,0 @@ -// Global styles for text -body { - font-size: 16px; - font-family: 'Roboto', sans-serif; - font-weight: 400; -} - -h1, -h2, -h3, -h4, -h5 { - font-family: 'Playfair Display', serif; -} - -h1, -.h1 { - font-size: 48px; -} - -h2, -.h2 { - font-size: 36px; - - @media (max-width: 767px) { - font-size: 24px; - } -} -h3, -.h3 { - font-size: 24px; -} -h4, -.h4 { - font-size: 18px; -} -h5, -.h5 { - font-size: 14px; -} -h6, -.h6 { - font-size: 12px; -} - -// Atomic styles for text -.serif { - font-family: 'Playfair Display', serif; -} -.sans-serif { - font-family: 'Roboto', sans-serif; -} - -.uppercase { - text-transform: uppercase; -} - -.align-center { - text-align: center; -} -.align-right { - text-align: right; -} -.align-left { - text-align: left; -} -.align-justify { - text-align: justify; -} - -.weight-400 { - font-weight: 400; -} -.weight-700 { - font-weight: 700; -} - -.lh16 { - line-height: 16px; -} -.lh20 { - line-height: 20px; -} -.lh25 { - line-height: 25px; -} -.lh30 { - line-height: 30px; -} -.lh35 { - line-height: 35px; -} -.lh40 { - line-height: 40px; -} - -// Sizes -.fs-medium-small { - font-size: 14px; -} -.fs-medium { - font-size: 18px; -} -.fs-large { - font-size: 24px; -} -.fs-big { - font-size: 36px; -} - -// @todo its not in styleguite therefore shouldn't be here, ask Karol Bzik -.fs16 { - font-size: 16px; -} diff --git a/src/themes/default-amp/css/components/_buttons.scss b/src/themes/default-amp/css/components/_buttons.scss deleted file mode 100755 index f14615f55..000000000 --- a/src/themes/default-amp/css/components/_buttons.scss +++ /dev/null @@ -1,47 +0,0 @@ -.ripple { - position: relative; - overflow: hidden; - &::after { - content: ''; - display: block; - position: absolute; - margin: 0; - padding: 0; - left: 50%; - top: 50%; - width: 120px; - height: 120px; - background-color: #f5f5f5; - border-radius: 50%; - opacity: .2; - transform: translate(-50%, -50%) scale(0); - visibility: hidden; - } - &:not(:active):after { - animation: ripple-effect 1s ease-out; - } - &:focus { - &::after { - visibility: visible; - } - } - &-dark { - &::after { - background-color: #ddd; - } - } -} - -.button-disabled { - opacity: 0.3; - pointer-events: none; -} - -button, -.button { - outline: none; - cursor: pointer; - margin: 0; - -webkit-tap-highlight-color: rgba(0,0,0,0); - -webkit-tap-highlight-color: transparent; -} diff --git a/src/themes/default-amp/css/helpers/_functions.scss b/src/themes/default-amp/css/helpers/_functions.scss deleted file mode 100755 index 48a30f39d..000000000 --- a/src/themes/default-amp/css/helpers/_functions.scss +++ /dev/null @@ -1,3 +0,0 @@ -// Global functions -// ================ -@import 'functions/color' diff --git a/src/themes/default-amp/css/helpers/_mixins.scss b/src/themes/default-amp/css/helpers/_mixins.scss deleted file mode 100755 index 03c91646e..000000000 --- a/src/themes/default-amp/css/helpers/_mixins.scss +++ /dev/null @@ -1,3 +0,0 @@ -// Global mixins -// ============= -@import 'mixins/color-selectors' diff --git a/src/themes/default-amp/css/helpers/functions/_color.scss b/src/themes/default-amp/css/helpers/functions/_color.scss deleted file mode 100755 index 57375dae7..000000000 --- a/src/themes/default-amp/css/helpers/functions/_color.scss +++ /dev/null @@ -1,16 +0,0 @@ -/// An easy way to get colors from the $colors map -/// @name color -/// @param {String} $color - Color name -/// @param {Map} $map [ map_merge($colors, $colors_theme] - Map of colors -@function color($color, $map: map_merge($colors, $colors-theme), $variant: default) { - @if (type-of(map-get($map, $color)) == map) { - @if map_has_key(map-get($map, $color), $variant) { - @return map-get(map-get($map, $color), $variant) - } @else { - @return map-get(map-get($map, $color), default) - } - } @else { - @return map-get($map, $color) - } - -} diff --git a/src/themes/default-amp/css/helpers/mixins/_color-selectors.scss b/src/themes/default-amp/css/helpers/mixins/_color-selectors.scss deleted file mode 100755 index 11d54da83..000000000 --- a/src/themes/default-amp/css/helpers/mixins/_color-selectors.scss +++ /dev/null @@ -1,33 +0,0 @@ -/// Generate colors values -/// @name color-selectors -/// @param {Map} $map [$colors] - Map with color values -/// @param {String} $selector ['.cl'] - Selector to css class, .#{$selector}-#{$name-of-color} -/// @param {String} $property ['color'] - CSS property -@mixin color-selectors($map: $colors, $selector: 'cl', $property: 'color') { - @each $name, $value in $map { - // in case when color value is map with hover, focus variants - @if type-of($value) == map { - @if map_has_key($value, default) { - .#{$selector}-#{$name} { - #{$property}: map-get($value, default); - } - } - - @if map_has_key($value, hover) { - .\:#{$selector}-#{$name}:hover { - #{$property}: map-get($value, hover); - } - } - - @if map_has_key($value, focus) { - .\:#{$selector}-#{$name}:focus { - #{$property}: map-get($value, focus); - } - } - } @else { - .#{$selector}-#{$name} { - #{$property}: #{$value}; - } - } - } -} diff --git a/src/themes/default-amp/css/layout/_border.scss b/src/themes/default-amp/css/layout/_border.scss deleted file mode 100755 index 56c556c16..000000000 --- a/src/themes/default-amp/css/layout/_border.scss +++ /dev/null @@ -1,46 +0,0 @@ -// Pixel sizes -$border-px: 1; - -// Radius -$border-radius: ( - square: 0, - circle: 50% -); - -@mixin border { - @each $i in $border-px { - .brdr-#{$i} { - border: #{$i}px solid; - } - .brdr-top-#{$i} { - border-top-width: #{$i}px; - border-top-style: solid; - } - .brdr-bottom-#{$i} { - border-bottom-width: #{$i}px; - border-bottom-style: solid; - } - .brdr-left-#{$i} { - border-left-width: #{$i}px; - border-left-style: solid; - } - .brdr-right-#{$i} { - border-right-width: #{$i}px; - border-right-style: solid; - } - } - @each $name, $value in $border-radius { - .brdr-#{$name} { - border-radius: #{$value}; - } - } - @include color-selectors(map_merge($colors, $colors-border), 'brdr-cl', 'border-color'); - @include color-selectors($colors-theme, 'brdr-cl-th', 'border-color'); - @include color-selectors($colors-background, 'brdr-cl-bg', 'border-color'); -} - -.brdr-none { - border: none; -} - -@include border; diff --git a/src/themes/default-amp/css/layout/_layout.scss b/src/themes/default-amp/css/layout/_layout.scss deleted file mode 100755 index b3142d9b2..000000000 --- a/src/themes/default-amp/css/layout/_layout.scss +++ /dev/null @@ -1,51 +0,0 @@ -.inline-flex { - display: inline-flex; -} - -.flex { - display: flex; -} - -.block { - display: block; -} - -.center-self { - align-self: center; -} - -.relative { - position: relative; -} - -.absolute { - position: absolute; -} - -.fixed { - position: fixed; -} - -.border-box { - box-sizing: border-box; -} - -.w-100 { - width: 100%; -} - -.w-auto { - width: auto; -} - -.h-100 { - height: 100%; -} - -.w-50 { - width: 50%; -} - -.mw-100 { - max-width: 100% -} diff --git a/src/themes/default-amp/css/layout/_margin.scss b/src/themes/default-amp/css/layout/_margin.scss deleted file mode 100755 index b67b36ae1..000000000 --- a/src/themes/default-amp/css/layout/_margin.scss +++ /dev/null @@ -1,102 +0,0 @@ -// Try to use multiples of 5 when using pixels - -// .m5 .mx5 .my5 .mt5 .mb5 .ml5 .mr5 - example classes for px margins -// .m5p .mx5p .my5p .mt5p .mb5p .ml5p .mr5p - example classes for % margins - -// Pixels -$margin-px: 0 5 10 40; -$margin-x-px: 5 10; -$margin-y-px: 0 5 10 15 20 30; -$margin-top-px: 0 5 8 10 15 20 25 30 35 50 55 60; -$margin-bottom-px: 0 5 10 15 20 25 30 35 40 45 55 80; -$margin-left-px: 10 15 20 30 40; -$margin-right-px: 0 5 10 15 20 35; - -// Percents -$margin-p: 5; -$margin-x-p: 5; -$margin-y-p: 5; -$margin-top-p: 5; -$margin-bottom-p: 5; -$margin-left-p: 5; -$margin-right-p: 5; - -// Generators -@mixin margin { - @each $i in $margin-px { - .m#{$i} { - margin: #{$i}px; - } - } - @each $i in $margin-p { - .m#{$i}p { - margin: percentage($i/100); - } - } - @each $i in $margin-x-px { - .mx#{$i} { - margin-left: #{$i}px; - margin-right: #{$i}px; - } - } - @each $i in $margin-x-p { - .mx#{$i}p { - margin-left: percentage($i/100); - margin-right: percentage($i/100); - } - } - @each $i in $margin-y-px { - .my#{$i} { - margin-top: #{$i}px; - margin-bottom: #{$i}px; - } - } - @each $i in $margin-y-p { - .my#{$i}p { - margin-top: percentage($i/100); - margin-bottom: percentage($i/100); - } - } - @each $i in $margin-top-px { - .mt#{$i} { - margin-top: #{$i}px; - } - } - @each $i in $margin-top-p { - .mt#{$i}p { - margin-top: percentage($i/100); - } - } - @each $i in $margin-bottom-px { - .mb#{$i} { - margin-bottom: #{$i}px; - } - } - @each $i in $margin-bottom-p { - .mb#{$i}p { - margin-bottom: percentage($i/100); - } - } - @each $i in $margin-left-px { - .ml#{$i} { - margin-left: #{$i}px; - } - } - @each $i in $margin-left-p { - .ml#{$i}p { - margin-left: percentage($i/100); - } - } - @each $i in $margin-right-px { - .mr#{$i} { - margin-right: #{$i}px; - } - } - @each $i in $margin-right-p { - .mr#{$i}p { - margin-right: percentage($i/100); - } - } -} - -@include margin; diff --git a/src/themes/default-amp/css/layout/_padding.scss b/src/themes/default-amp/css/layout/_padding.scss deleted file mode 100755 index 2c9b07911..000000000 --- a/src/themes/default-amp/css/layout/_padding.scss +++ /dev/null @@ -1,66 +0,0 @@ -// IMPORTANT: Add new css rules ON DEMAND only when you need them! -// Try to use multiples of 5 when using pixels - -// .p0 .px10 .py5 - example classes for px paddings -// .px10p - example class for % padding - -// Pixels -$padding-px: 0 5 10 12 15 20 25 45 50; -$padding-x-px: 2 10 15 20 25 40 55 65 70; -$padding-y-px: 0 5 10 15 20 25 30 35 40 50; -$padding-top-px: 0 5 10 15 20 25 30 35 40 45 50 55 70; -$padding-bottom-px: 10 15 20 30 35 40 45 50 60 70; -$padding-left-px: 0 20 30 35 40 70; -$padding-right-px: 0 5 15 20 30 55 70; - -// Percents -$padding-x-p: 10; - -// Generators -@mixin padding { - @each $i in $padding-px { - .p#{$i} { - padding: #{$i}px; - } - } - @each $i in $padding-x-px { - .px#{$i} { - padding-left: #{$i}px; - padding-right: #{$i}px; - } - } - @each $i in $padding-x-p { - .px#{$i}p { - padding-left: percentage($i/100); - padding-right: percentage($i/100); - } - } - @each $i in $padding-y-px { - .py#{$i} { - padding-top: #{$i}px; - padding-bottom: #{$i}px; - } - } - @each $i in $padding-top-px { - .pt#{$i} { - padding-top: #{$i}px; - } - } - @each $i in $padding-bottom-px { - .pb#{$i} { - padding-bottom: #{$i}px; - } - } - @each $i in $padding-right-px { - .pr#{$i} { - padding-right: #{$i}px; - } - } - @each $i in $padding-left-px { - .pl#{$i} { - padding-left: #{$i}px; - } - } -} - -@include padding; diff --git a/src/themes/default-amp/css/main.scss b/src/themes/default-amp/css/main.scss deleted file mode 100755 index 04dc5cbf1..000000000 --- a/src/themes/default-amp/css/main.scss +++ /dev/null @@ -1,24 +0,0 @@ -@import 'vendor/flexboxgrid2'; - -@import 'variables/variables'; -@import 'helpers/mixins'; -@import 'helpers/functions'; -@import 'base/base'; -@import 'base/flexbox-grid'; -@import 'base/global_vars'; -@import 'base/text'; -@import 'base/color'; -@import 'base/icons'; - -@import 'layout/layout'; -@import 'layout/border'; -@import 'layout/margin'; -@import 'layout/padding'; - -@import 'components/buttons'; - -@import 'utilities/utilities'; -@import 'utilities/visibility'; - -@import 'animations/animations'; -@import 'animations/transitions'; diff --git a/src/themes/default-amp/css/utilities/_utilities.scss b/src/themes/default-amp/css/utilities/_utilities.scss deleted file mode 100755 index 5347c1e87..000000000 --- a/src/themes/default-amp/css/utilities/_utilities.scss +++ /dev/null @@ -1,28 +0,0 @@ -.no-outline { - outline: 0; -} - -.pointer { - cursor: pointer; -} - -a.underline:after, -a:not(.no-underline):hover:after { - content: ""; - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 1px; - background-color: color(primary, $colors-border) -} - -.no-underline:hover:after { - height: 0; -} - -@media (hover: none) { - a:not(.no-underline):after { - display: none; - } -} diff --git a/src/themes/default-amp/css/utilities/_visibility.scss b/src/themes/default-amp/css/utilities/_visibility.scss deleted file mode 100755 index 9cc02787f..000000000 --- a/src/themes/default-amp/css/utilities/_visibility.scss +++ /dev/null @@ -1,35 +0,0 @@ -[v-cloak], -.hidden { - display: none; -} - -.hidden-xs { - display: none; - - @media only screen and (min-width:768px) { - display: inherit - } -} - -.hidden-md { - @media only screen and (min-width:768px) { - display: none; - } -} - -@media only screen and (min-width:768px) { - .visible-xs { - display: none; - } -} - -.visually-hidden { - position: absolute; - overflow: hidden; - clip: rect(0 0 0 0); - width: 1px; - height: 1px; - margin: -1px; - padding: 0; - border: 0; -} diff --git a/src/themes/default-amp/css/variables/_colors.scss b/src/themes/default-amp/css/variables/_colors.scss deleted file mode 100755 index 16d4fa4d5..000000000 --- a/src/themes/default-amp/css/variables/_colors.scss +++ /dev/null @@ -1,62 +0,0 @@ -// Colors -// ====== -// @LandonSchropp's approach of naming color variables. -// - http://davidwalsh.name/sass-color-variables-dont-suck -// - http://www.color-blindness.com/color-name-hue/ - -// General colors palette -$colors: ( - transparent: transparent, - black: #000, - white: #fff, - white-smoke: #f2f2f2, - gainsboro: #e0e0e0, - silver: #bdbdbd, - gray: #828282, - suva-gray: #8e8e8e, - matterhorn: #4f4f4f, - burnt-sienna: #eb5757, - buccaneer: #755, - forest-green: #308c14, - puerto-rico: #4dba87, - mine-shaft: #333 -); - -// Theme generic colors -// Key: name of color -// Value: color value or map with default, hover, focus color values -$colors-theme: ( - primary: ( - default: map-get($colors, matterhorn), - hover: map-get($colors, matterhorn) - ), - secondary: ( - default: map-get($colors, gray), - hover: map-get($colors, matterhorn), - ), - accent: ( - default: map-get($colors, matterhorn), - hover: map-get($colors, black), - ), - tertiary: map-get($colors, silver), - success: map-get($colors, puerto-rico), - warning: map-get($colors, buccaneer), - error: map-get($colors, burnt-sienna) -); - -$colors-border: ( - primary: map-get($colors, silver), - secondary: map-get($colors, gainsboro), -); - -$colors-background: ( - primary: ( - default: map-get($colors, white), - hover: map-get($colors, silver) - ), - secondary: ( - default: map-get($colors, white-smoke), - hover: map-get($colors, gainsboro) - ), - tertiary: map-get($colors, suva-gray) -); diff --git a/src/themes/default-amp/css/variables/_variables.scss b/src/themes/default-amp/css/variables/_variables.scss deleted file mode 100755 index 1a5baa0a5..000000000 --- a/src/themes/default-amp/css/variables/_variables.scss +++ /dev/null @@ -1,5 +0,0 @@ -// Default theme variables -// ======================= - -// Colors -@import 'colors'; diff --git a/src/themes/default-amp/css/vendor/_flexboxgrid2.scss b/src/themes/default-amp/css/vendor/_flexboxgrid2.scss deleted file mode 100755 index 567c7dc1f..000000000 --- a/src/themes/default-amp/css/vendor/_flexboxgrid2.scss +++ /dev/null @@ -1,1209 +0,0 @@ -.container { - box-sizing: border-box; - margin-left: auto; - margin-right: auto; - padding-right: 8px; - padding-left: 8px; -} - -.container-fluid { - padding-right: 16px; - padding-left: 16px; -} - -@media only screen and (min-width: 576px) { - .container { - width: 560px; - max-width: 100%; - } -} - -@media only screen and (min-width: 768px) { - .container { - width: 752px; - max-width: 100%; - } -} - -@media only screen and (min-width: 992px) { - .container { - width: 976px; - max-width: 100%; - } -} - -@media only screen and (min-width: 1200px) { - .container { - width: 1184px; - max-width: 100%; - } -} - -.row { - box-sizing: border-box; - display: -webkit-box; - display: -ms-flexbox; - display: flex; - -webkit-box-flex: 0; - -ms-flex: 0 1 auto; - flex: 0 1 auto; - -webkit-box-orient: horizontal; - -webkit-box-direction: normal; - -ms-flex-direction: row; - flex-direction: row; - -ms-flex-wrap: wrap; - flex-wrap: wrap; - margin-right: -8px; - margin-left: -8px; -} - -.row.reverse { - -webkit-box-orient: horizontal; - -webkit-box-direction: reverse; - -ms-flex-direction: row-reverse; - flex-direction: row-reverse; -} - -.col.reverse { - -webkit-box-orient: vertical; - -webkit-box-direction: reverse; - -ms-flex-direction: column-reverse; - flex-direction: column-reverse; -} - -[class^="col-"] { - box-sizing: border-box; - -webkit-box-flex: 0; - -ms-flex: 0 0 auto; - flex: 0 0 auto; - -ms-flex-preferred-size: 100%; - flex-basis: 100%; - padding-right: 8px; - padding-left: 8px; - max-width: 100%; -} - -.col-xs { - -webkit-box-flex: 1; - -ms-flex-positive: 1; - flex-grow: 1; - -ms-flex-preferred-size: 0; - flex-basis: 0; - max-width: 100%; -} - -.col-xs-1 { - -ms-flex-preferred-size: 8.33333333%; - flex-basis: 8.33333333%; - max-width: 8.33333333%; -} - -.col-xs-2 { - -ms-flex-preferred-size: 16.66666667%; - flex-basis: 16.66666667%; - max-width: 16.66666667%; -} - -.col-xs-3 { - -ms-flex-preferred-size: 25%; - flex-basis: 25%; - max-width: 25%; -} - -.col-xs-4 { - -ms-flex-preferred-size: 33.33333333%; - flex-basis: 33.33333333%; - max-width: 33.33333333%; -} - -.col-xs-5 { - -ms-flex-preferred-size: 41.66666667%; - flex-basis: 41.66666667%; - max-width: 41.66666667%; -} - -.col-xs-6 { - -ms-flex-preferred-size: 50%; - flex-basis: 50%; - max-width: 50%; -} - -.col-xs-7 { - -ms-flex-preferred-size: 58.33333333%; - flex-basis: 58.33333333%; - max-width: 58.33333333%; -} - -.col-xs-8 { - -ms-flex-preferred-size: 66.66666667%; - flex-basis: 66.66666667%; - max-width: 66.66666667%; -} - -.col-xs-9 { - -ms-flex-preferred-size: 75%; - flex-basis: 75%; - max-width: 75%; -} - -.col-xs-10 { - -ms-flex-preferred-size: 83.33333333%; - flex-basis: 83.33333333%; - max-width: 83.33333333%; -} - -.col-xs-11 { - -ms-flex-preferred-size: 91.66666667%; - flex-basis: 91.66666667%; - max-width: 91.66666667%; -} - -.col-xs-12 { - -ms-flex-preferred-size: 100%; - flex-basis: 100%; - max-width: 100%; -} - -.col-xs-offset-0 { - margin-left: 0; -} - -.col-xs-offset-1 { - margin-left: 8.33333333%; -} - -.col-xs-offset-2 { - margin-left: 16.66666667%; -} - -.col-xs-offset-3 { - margin-left: 25%; -} - -.col-xs-offset-4 { - margin-left: 33.33333333%; -} - -.col-xs-offset-5 { - margin-left: 41.66666667%; -} - -.col-xs-offset-6 { - margin-left: 50%; -} - -.col-xs-offset-7 { - margin-left: 58.33333333%; -} - -.col-xs-offset-8 { - margin-left: 66.66666667%; -} - -.col-xs-offset-9 { - margin-left: 75%; -} - -.col-xs-offset-10 { - margin-left: 83.33333333%; -} - -.col-xs-offset-11 { - margin-left: 91.66666667%; -} - -.start-xs { - -webkit-box-pack: start; - -ms-flex-pack: start; - justify-content: flex-start; - text-align: start; -} - -.center-xs { - -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; - text-align: center; -} - -.end-xs { - -webkit-box-pack: end; - -ms-flex-pack: end; - justify-content: flex-end; - text-align: end; -} - -.top-xs { - -webkit-box-align: start; - -ms-flex-align: start; - align-items: flex-start; -} - -.middle-xs { - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; -} - -.bottom-xs { - -webkit-box-align: end; - -ms-flex-align: end; - align-items: flex-end; -} - -.around-xs { - -ms-flex-pack: distribute; - justify-content: space-around; -} - -.between-xs { - -webkit-box-pack: justify; - -ms-flex-pack: justify; - justify-content: space-between; -} - -.first-xs { - -webkit-box-ordinal-group: 0; - -ms-flex-order: -1; - order: -1; -} - -.last-xs { - -webkit-box-ordinal-group: 2; - -ms-flex-order: 1; - order: 1; -} - -.initial-order-xs { - -webkit-box-ordinal-group: NaN; - -ms-flex-order: initial; - order: initial; -} - -@media only screen and (min-width: 576px) { - .col-sm { - -webkit-box-flex: 1; - -ms-flex-positive: 1; - flex-grow: 1; - -ms-flex-preferred-size: 0; - flex-basis: 0; - max-width: 100%; - } - - .col-sm-1 { - -ms-flex-preferred-size: 8.33333333%; - flex-basis: 8.33333333%; - max-width: 8.33333333%; - } - - .col-sm-2 { - -ms-flex-preferred-size: 16.66666667%; - flex-basis: 16.66666667%; - max-width: 16.66666667%; - } - - .col-sm-3 { - -ms-flex-preferred-size: 25%; - flex-basis: 25%; - max-width: 25%; - } - - .col-sm-4 { - -ms-flex-preferred-size: 33.33333333%; - flex-basis: 33.33333333%; - max-width: 33.33333333%; - } - - .col-sm-5 { - -ms-flex-preferred-size: 41.66666667%; - flex-basis: 41.66666667%; - max-width: 41.66666667%; - } - - .col-sm-6 { - -ms-flex-preferred-size: 50%; - flex-basis: 50%; - max-width: 50%; - } - - .col-sm-7 { - -ms-flex-preferred-size: 58.33333333%; - flex-basis: 58.33333333%; - max-width: 58.33333333%; - } - - .col-sm-8 { - -ms-flex-preferred-size: 66.66666667%; - flex-basis: 66.66666667%; - max-width: 66.66666667%; - } - - .col-sm-9 { - -ms-flex-preferred-size: 75%; - flex-basis: 75%; - max-width: 75%; - } - - .col-sm-10 { - -ms-flex-preferred-size: 83.33333333%; - flex-basis: 83.33333333%; - max-width: 83.33333333%; - } - - .col-sm-11 { - -ms-flex-preferred-size: 91.66666667%; - flex-basis: 91.66666667%; - max-width: 91.66666667%; - } - - .col-sm-12 { - -ms-flex-preferred-size: 100%; - flex-basis: 100%; - max-width: 100%; - } - - .col-sm-offset-0 { - margin-left: 0; - } - - .col-sm-offset-1 { - margin-left: 8.33333333%; - } - - .col-sm-offset-2 { - margin-left: 16.66666667%; - } - - .col-sm-offset-3 { - margin-left: 25%; - } - - .col-sm-offset-4 { - margin-left: 33.33333333%; - } - - .col-sm-offset-5 { - margin-left: 41.66666667%; - } - - .col-sm-offset-6 { - margin-left: 50%; - } - - .col-sm-offset-7 { - margin-left: 58.33333333%; - } - - .col-sm-offset-8 { - margin-left: 66.66666667%; - } - - .col-sm-offset-9 { - margin-left: 75%; - } - - .col-sm-offset-10 { - margin-left: 83.33333333%; - } - - .col-sm-offset-11 { - margin-left: 91.66666667%; - } - - .start-sm { - -webkit-box-pack: start; - -ms-flex-pack: start; - justify-content: flex-start; - text-align: start; - } - - .center-sm { - -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; - text-align: center; - } - - .end-sm { - -webkit-box-pack: end; - -ms-flex-pack: end; - justify-content: flex-end; - text-align: end; - } - - .top-sm { - -webkit-box-align: start; - -ms-flex-align: start; - align-items: flex-start; - } - - .middle-sm { - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - } - - .bottom-sm { - -webkit-box-align: end; - -ms-flex-align: end; - align-items: flex-end; - } - - .around-sm { - -ms-flex-pack: distribute; - justify-content: space-around; - } - - .between-sm { - -webkit-box-pack: justify; - -ms-flex-pack: justify; - justify-content: space-between; - } - - .first-sm { - -webkit-box-ordinal-group: 0; - -ms-flex-order: -1; - order: -1; - } - - .last-sm { - -webkit-box-ordinal-group: 2; - -ms-flex-order: 1; - order: 1; - } - - .initial-order-sm { - -webkit-box-ordinal-group: NaN; - -ms-flex-order: initial; - order: initial; - } -} - -@media only screen and (min-width: 768px) { - .col-md, - .col-md-1, - .col-md-2, - .col-md-3, - .col-md-4, - .col-md-5, - .col-md-6, - .col-md-7, - .col-md-8, - .col-md-9, - .col-md-10, - .col-md-11, - .col-md-12, - .col-md-offset-0, - .col-md-offset-1, - .col-md-offset-2, - .col-md-offset-3, - .col-md-offset-4, - .col-md-offset-5, - .col-md-offset-6, - .col-md-offset-7, - .col-md-offset-8, - .col-md-offset-9, - .col-md-offset-10, - .col-md-offset-11, - .col-md-offset-12 { - box-sizing: border-box; - -webkit-box-flex: 0; - -ms-flex: 0 0 auto; - flex: 0 0 auto; - padding-right: 8px; - padding-left: 8px; - } - - .col-md { - -webkit-box-flex: 1; - -ms-flex-positive: 1; - flex-grow: 1; - -ms-flex-preferred-size: 0; - flex-basis: 0; - max-width: 100%; - } - - .col-md-1 { - -ms-flex-preferred-size: 8.33333333%; - flex-basis: 8.33333333%; - max-width: 8.33333333%; - } - - .col-md-2 { - -ms-flex-preferred-size: 16.66666667%; - flex-basis: 16.66666667%; - max-width: 16.66666667%; - } - - .col-md-3 { - -ms-flex-preferred-size: 25%; - flex-basis: 25%; - max-width: 25%; - } - - .col-md-4 { - -ms-flex-preferred-size: 33.33333333%; - flex-basis: 33.33333333%; - max-width: 33.33333333%; - } - - .col-md-5 { - -ms-flex-preferred-size: 41.66666667%; - flex-basis: 41.66666667%; - max-width: 41.66666667%; - } - - .col-md-6 { - -ms-flex-preferred-size: 50%; - flex-basis: 50%; - max-width: 50%; - } - - .col-md-7 { - -ms-flex-preferred-size: 58.33333333%; - flex-basis: 58.33333333%; - max-width: 58.33333333%; - } - - .col-md-8 { - -ms-flex-preferred-size: 66.66666667%; - flex-basis: 66.66666667%; - max-width: 66.66666667%; - } - - .col-md-9 { - -ms-flex-preferred-size: 75%; - flex-basis: 75%; - max-width: 75%; - } - - .col-md-10 { - -ms-flex-preferred-size: 83.33333333%; - flex-basis: 83.33333333%; - max-width: 83.33333333%; - } - - .col-md-11 { - -ms-flex-preferred-size: 91.66666667%; - flex-basis: 91.66666667%; - max-width: 91.66666667%; - } - - .col-md-12 { - -ms-flex-preferred-size: 100%; - flex-basis: 100%; - max-width: 100%; - } - - .col-md-offset-0 { - margin-left: 0; - } - - .col-md-offset-1 { - margin-left: 8.33333333%; - } - - .col-md-offset-2 { - margin-left: 16.66666667%; - } - - .col-md-offset-3 { - margin-left: 25%; - } - - .col-md-offset-4 { - margin-left: 33.33333333%; - } - - .col-md-offset-5 { - margin-left: 41.66666667%; - } - - .col-md-offset-6 { - margin-left: 50%; - } - - .col-md-offset-7 { - margin-left: 58.33333333%; - } - - .col-md-offset-8 { - margin-left: 66.66666667%; - } - - .col-md-offset-9 { - margin-left: 75%; - } - - .col-md-offset-10 { - margin-left: 83.33333333%; - } - - .col-md-offset-11 { - margin-left: 91.66666667%; - } - - .start-md { - -webkit-box-pack: start; - -ms-flex-pack: start; - justify-content: flex-start; - text-align: start; - } - - .center-md { - -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; - text-align: center; - } - - .end-md { - -webkit-box-pack: end; - -ms-flex-pack: end; - justify-content: flex-end; - text-align: end; - } - - .top-md { - -webkit-box-align: start; - -ms-flex-align: start; - align-items: flex-start; - } - - .middle-md { - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - } - - .bottom-md { - -webkit-box-align: end; - -ms-flex-align: end; - align-items: flex-end; - } - - .around-md { - -ms-flex-pack: distribute; - justify-content: space-around; - } - - .between-md { - -webkit-box-pack: justify; - -ms-flex-pack: justify; - justify-content: space-between; - } - - .first-md { - -webkit-box-ordinal-group: 0; - -ms-flex-order: -1; - order: -1; - } - - .last-md { - -webkit-box-ordinal-group: 2; - -ms-flex-order: 1; - order: 1; - } - - .initial-order-md { - -webkit-box-ordinal-group: NaN; - -ms-flex-order: initial; - order: initial; - } -} - -@media only screen and (min-width: 992px) { - .col-lg, - .col-lg-1, - .col-lg-2, - .col-lg-3, - .col-lg-4, - .col-lg-5, - .col-lg-6, - .col-lg-7, - .col-lg-8, - .col-lg-9, - .col-lg-10, - .col-lg-11, - .col-lg-12, - .col-lg-offset-0, - .col-lg-offset-1, - .col-lg-offset-2, - .col-lg-offset-3, - .col-lg-offset-4, - .col-lg-offset-5, - .col-lg-offset-6, - .col-lg-offset-7, - .col-lg-offset-8, - .col-lg-offset-9, - .col-lg-offset-10, - .col-lg-offset-11, - .col-lg-offset-12 { - box-sizing: border-box; - -webkit-box-flex: 0; - -ms-flex: 0 0 auto; - flex: 0 0 auto; - padding-right: 8px; - padding-left: 8px; - } - - .col-lg { - -webkit-box-flex: 1; - -ms-flex-positive: 1; - flex-grow: 1; - -ms-flex-preferred-size: 0; - flex-basis: 0; - max-width: 100%; - } - - .col-lg-1 { - -ms-flex-preferred-size: 8.33333333%; - flex-basis: 8.33333333%; - max-width: 8.33333333%; - } - - .col-lg-2 { - -ms-flex-preferred-size: 16.66666667%; - flex-basis: 16.66666667%; - max-width: 16.66666667%; - } - - .col-lg-3 { - -ms-flex-preferred-size: 25%; - flex-basis: 25%; - max-width: 25%; - } - - .col-lg-4 { - -ms-flex-preferred-size: 33.33333333%; - flex-basis: 33.33333333%; - max-width: 33.33333333%; - } - - .col-lg-5 { - -ms-flex-preferred-size: 41.66666667%; - flex-basis: 41.66666667%; - max-width: 41.66666667%; - } - - .col-lg-6 { - -ms-flex-preferred-size: 50%; - flex-basis: 50%; - max-width: 50%; - } - - .col-lg-7 { - -ms-flex-preferred-size: 58.33333333%; - flex-basis: 58.33333333%; - max-width: 58.33333333%; - } - - .col-lg-8 { - -ms-flex-preferred-size: 66.66666667%; - flex-basis: 66.66666667%; - max-width: 66.66666667%; - } - - .col-lg-9 { - -ms-flex-preferred-size: 75%; - flex-basis: 75%; - max-width: 75%; - } - - .col-lg-10 { - -ms-flex-preferred-size: 83.33333333%; - flex-basis: 83.33333333%; - max-width: 83.33333333%; - } - - .col-lg-11 { - -ms-flex-preferred-size: 91.66666667%; - flex-basis: 91.66666667%; - max-width: 91.66666667%; - } - - .col-lg-12 { - -ms-flex-preferred-size: 100%; - flex-basis: 100%; - max-width: 100%; - } - - .col-lg-offset-0 { - margin-left: 0; - } - - .col-lg-offset-1 { - margin-left: 8.33333333%; - } - - .col-lg-offset-2 { - margin-left: 16.66666667%; - } - - .col-lg-offset-3 { - margin-left: 25%; - } - - .col-lg-offset-4 { - margin-left: 33.33333333%; - } - - .col-lg-offset-5 { - margin-left: 41.66666667%; - } - - .col-lg-offset-6 { - margin-left: 50%; - } - - .col-lg-offset-7 { - margin-left: 58.33333333%; - } - - .col-lg-offset-8 { - margin-left: 66.66666667%; - } - - .col-lg-offset-9 { - margin-left: 75%; - } - - .col-lg-offset-10 { - margin-left: 83.33333333%; - } - - .col-lg-offset-11 { - margin-left: 91.66666667%; - } - - .start-lg { - -webkit-box-pack: start; - -ms-flex-pack: start; - justify-content: flex-start; - text-align: start; - } - - .center-lg { - -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; - text-align: center; - } - - .end-lg { - -webkit-box-pack: end; - -ms-flex-pack: end; - justify-content: flex-end; - text-align: end; - } - - .top-lg { - -webkit-box-align: start; - -ms-flex-align: start; - align-items: flex-start; - } - - .middle-lg { - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - } - - .bottom-lg { - -webkit-box-align: end; - -ms-flex-align: end; - align-items: flex-end; - } - - .around-lg { - -ms-flex-pack: distribute; - justify-content: space-around; - } - - .between-lg { - -webkit-box-pack: justify; - -ms-flex-pack: justify; - justify-content: space-between; - } - - .first-lg { - -webkit-box-ordinal-group: 0; - -ms-flex-order: -1; - order: -1; - } - - .last-lg { - -webkit-box-ordinal-group: 2; - -ms-flex-order: 1; - order: 1; - } - - .initial-order-lg { - -webkit-box-ordinal-group: NaN; - -ms-flex-order: initial; - order: initial; - } -} - -@media only screen and (min-width: 1200px) { - .col-xl, - .col-xl-1, - .col-xl-2, - .col-xl-3, - .col-xl-4, - .col-xl-5, - .col-xl-6, - .col-xl-7, - .col-xl-8, - .col-xl-9, - .col-xl-10, - .col-xl-11, - .col-xl-12, - .col-xl-offset-0, - .col-xl-offset-1, - .col-xl-offset-2, - .col-xl-offset-3, - .col-xl-offset-4, - .col-xl-offset-5, - .col-xl-offset-6, - .col-xl-offset-7, - .col-xl-offset-8, - .col-xl-offset-9, - .col-xl-offset-10, - .col-xl-offset-11, - .col-xl-offset-12 { - box-sizing: border-box; - -webkit-box-flex: 0; - -ms-flex: 0 0 auto; - flex: 0 0 auto; - padding-right: 8px; - padding-left: 8px; - } - - .col-xl { - -webkit-box-flex: 1; - -ms-flex-positive: 1; - flex-grow: 1; - -ms-flex-preferred-size: 0; - flex-basis: 0; - max-width: 100%; - } - - .col-xl-1 { - -ms-flex-preferred-size: 8.33333333%; - flex-basis: 8.33333333%; - max-width: 8.33333333%; - } - - .col-xl-2 { - -ms-flex-preferred-size: 16.66666667%; - flex-basis: 16.66666667%; - max-width: 16.66666667%; - } - - .col-xl-3 { - -ms-flex-preferred-size: 25%; - flex-basis: 25%; - max-width: 25%; - } - - .col-xl-4 { - -ms-flex-preferred-size: 33.33333333%; - flex-basis: 33.33333333%; - max-width: 33.33333333%; - } - - .col-xl-5 { - -ms-flex-preferred-size: 41.66666667%; - flex-basis: 41.66666667%; - max-width: 41.66666667%; - } - - .col-xl-6 { - -ms-flex-preferred-size: 50%; - flex-basis: 50%; - max-width: 50%; - } - - .col-xl-7 { - -ms-flex-preferred-size: 58.33333333%; - flex-basis: 58.33333333%; - max-width: 58.33333333%; - } - - .col-xl-8 { - -ms-flex-preferred-size: 66.66666667%; - flex-basis: 66.66666667%; - max-width: 66.66666667%; - } - - .col-xl-9 { - -ms-flex-preferred-size: 75%; - flex-basis: 75%; - max-width: 75%; - } - - .col-xl-10 { - -ms-flex-preferred-size: 83.33333333%; - flex-basis: 83.33333333%; - max-width: 83.33333333%; - } - - .col-xl-11 { - -ms-flex-preferred-size: 91.66666667%; - flex-basis: 91.66666667%; - max-width: 91.66666667%; - } - - .col-xl-12 { - -ms-flex-preferred-size: 100%; - flex-basis: 100%; - max-width: 100%; - } - - .col-xl-offset-0 { - margin-left: 0; - } - - .col-xl-offset-1 { - margin-left: 8.33333333%; - } - - .col-xl-offset-2 { - margin-left: 16.66666667%; - } - - .col-xl-offset-3 { - margin-left: 25%; - } - - .col-xl-offset-4 { - margin-left: 33.33333333%; - } - - .col-xl-offset-5 { - margin-left: 41.66666667%; - } - - .col-xl-offset-6 { - margin-left: 50%; - } - - .col-xl-offset-7 { - margin-left: 58.33333333%; - } - - .col-xl-offset-8 { - margin-left: 66.66666667%; - } - - .col-xl-offset-9 { - margin-left: 75%; - } - - .col-xl-offset-10 { - margin-left: 83.33333333%; - } - - .col-xl-offset-11 { - margin-left: 91.66666667%; - } - - .start-xl { - -webkit-box-pack: start; - -ms-flex-pack: start; - justify-content: flex-start; - text-align: start; - } - - .center-xl { - -webkit-box-pack: center; - -ms-flex-pack: center; - justify-content: center; - text-align: center; - } - - .end-xl { - -webkit-box-pack: end; - -ms-flex-pack: end; - justify-content: flex-end; - text-align: end; - } - - .top-xl { - -webkit-box-align: start; - -ms-flex-align: start; - align-items: flex-start; - } - - .middle-xl { - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - } - - .bottom-xl { - -webkit-box-align: end; - -ms-flex-align: end; - align-items: flex-end; - } - - .around-xl { - -ms-flex-pack: distribute; - justify-content: space-around; - } - - .between-xl { - -webkit-box-pack: justify; - -ms-flex-pack: justify; - justify-content: space-between; - } - - .first-xl { - -webkit-box-ordinal-group: 0; - -ms-flex-order: -1; - order: -1; - } - - .last-xl { - -webkit-box-ordinal-group: 2; - -ms-flex-order: 1; - order: 1; - } - - .initial-order-xl { - -webkit-box-ordinal-group: NaN; - -ms-flex-order: initial; - order: initial; - } -} - -@media only screen and (max-width: 575px) { - .hidden-xs { - display: none; - } -} - -@media only screen and (min-width: 576px) and (max-width: 767px) { - .hidden-sm { - display: none; - } -} - -@media only screen and (min-width: 768px) and (max-width: 991px) { - .hidden-md { - display: none; - } -} - -@media only screen and (min-width: 992px) and (max-width: 1199px) { - .hidden-lg { - display: none; - } -} - -@media only screen and (min-width: 1200px) { - .hidden-xl { - display: none; - } -} \ No newline at end of file diff --git a/src/themes/default-amp/index.js b/src/themes/default-amp/index.js deleted file mode 100755 index 36b484635..000000000 --- a/src/themes/default-amp/index.js +++ /dev/null @@ -1,11 +0,0 @@ -import { setupMultistoreRoutes } from '@vue-storefront/core/lib/multistore' -import config from 'config' -import routes from './router' - -export default function (app, router, store) { - // if you're running multistore setup this is copying the routed above adding the 'storeCode' prefix to the urls and the names of the routes - // You can do it on your own and then be able to customize the components used for example for German storeView checkout - // To do so please exclude the desired storeView from the config.storeViews.mapStoreUrlsFor and map the urls by your own like: - // { name: 'de-checkout', path: '/checkout', component: CheckoutCustomized }, - setupMultistoreRoutes(config, router, routes, 10) -} diff --git a/src/themes/default-amp/package.json b/src/themes/default-amp/package.json deleted file mode 100755 index dd8fc665d..000000000 --- a/src/themes/default-amp/package.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "@vue-storefront/theme-default-amp", - "version": "1.11.4", - "description": "Default AMP theme for Vue Storefront", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", - "dev": "cd ../../../ && node core/scripts/server" - }, - "author": "pkarw and contributors", - "license": "MIT", - "dependencies": { - "bodybuilder": "2.2.21", - "vue": "^2.6.11", - "vue-carousel": "^0.18", - "vue-no-ssr": "^0.2.2", - "vue-progressbar": "^0.7.5", - "vuelidate": "^0.7.5", - "vuex": "^3.1.2" - }, - "publishConfig": { - "access": "public" - } -} diff --git a/src/themes/default-amp/pages/Category.vue b/src/themes/default-amp/pages/Category.vue deleted file mode 100755 index d9143413d..000000000 --- a/src/themes/default-amp/pages/Category.vue +++ /dev/null @@ -1,166 +0,0 @@ - - - - - diff --git a/src/themes/default-amp/pages/Product.vue b/src/themes/default-amp/pages/Product.vue deleted file mode 100755 index 6753b018b..000000000 --- a/src/themes/default-amp/pages/Product.vue +++ /dev/null @@ -1,426 +0,0 @@ - - - - - - - diff --git a/src/themes/default-amp/router/index.ts b/src/themes/default-amp/router/index.ts deleted file mode 100755 index ce291ac22..000000000 --- a/src/themes/default-amp/router/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -// import router from '@vue-storefront/core/router' -// uncomment if you want to modify the router e.g. add before/after hooks -import Category from '../pages/Category.vue' -import Product from '../pages/Product.vue' -import { RouteConfig } from 'vue-router' -let routes: RouteConfig[] = [ -] -routes = routes.concat([{ name: 'virtual-product-amp', path: '/amp/p/:parentSku/:slug', component: Product, meta: { layout: 'minimal' } }, // :sku param can be marked as optional with ":sku?" (https://github.com/vuejs/vue-router/blob/dev/examples/route-matching/app.js#L16), but it requires a lot of work to adjust the rest of the site - { name: 'bundle-product-amp', path: '/amp/p/:parentSku/:slug', component: Product, meta: { layout: 'minimal' } }, // :sku param can be marked as optional with ":sku?" (https://github.com/vuejs/vue-router/blob/dev/examples/route-matching/app.js#L16), but it requires a lot of work to adjust the rest of the site - { name: 'simple-product-amp', path: '/amp/p/:parentSku/:slug', component: Product, meta: { layout: 'minimal' } }, // :sku param can be marked as optional with ":sku?" (https://github.com/vuejs/vue-router/blob/dev/examples/route-matching/app.js#L16), but it requires a lot of work to adjust the rest of the site - { name: 'downloadable-product-amp', path: '/amp/p/:parentSku/:slug', component: Product, meta: { layout: 'minimal' } }, // :sku param can be marked as optional with ":sku?" (https://github.com/vuejs/vue-router/blob/dev/examples/route-matching/app.js#L16), but it requires a lot of work to adjust the rest of the site - { name: 'grouped-product-amp', path: '/amp/p/:parentSku/:slug', component: Product, meta: { layout: 'minimal' } }, // :sku param can be marked as optional with ":sku?" (https://github.com/vuejs/vue-router/blob/dev/examples/route-matching/app.js#L16), but it requires a lot of work to adjust the rest of the site - { name: 'configurable-product-amp', path: '/amp/p/:parentSku/:slug/:childSku', component: Product, meta: { layout: 'minimal' } }, // :sku param can be marked as optional with ":sku?" (https://github.com/vuejs/vue-router/blob/dev/examples/route-matching/app.js#L16), but it requires a lot of work to adjust the rest of the site - { name: 'product-amp', path: '/amp/p/:parentSku/:slug/:childSku', component: Product, meta: { layout: 'minimal' } }, // :sku param can be marked as optional with ":sku?" (https://github.com/vuejs/vue-router/blob/dev/examples/route-matching/app.js#L16), but it requires a lot of work to adjust the rest of the site - { name: 'category-amp', path: '/amp/c/:slug', component: Category }]) -export default routes diff --git a/src/themes/default-amp/webpack.config.js b/src/themes/default-amp/webpack.config.js deleted file mode 100755 index 4aea92873..000000000 --- a/src/themes/default-amp/webpack.config.js +++ /dev/null @@ -1,4 +0,0 @@ -// You can extend default webpack build here. Read more on docs: https://github.com/DivanteLtd/vue-storefront/blob/master/docs/guide/core-themes/webpack.md -module.exports = function (config, { isClient, isDev }) { - return config -} diff --git a/src/themes/default/App.vue b/src/themes/default/App.vue deleted file mode 100755 index cb8bcb73a..000000000 --- a/src/themes/default/App.vue +++ /dev/null @@ -1,35 +0,0 @@ - - - diff --git a/src/themes/default/assets/android-icon-144x144.png b/src/themes/default/assets/android-icon-144x144.png deleted file mode 100644 index c9e52ac05..000000000 Binary files a/src/themes/default/assets/android-icon-144x144.png and /dev/null differ diff --git a/src/themes/default/assets/android-icon-168x168.png b/src/themes/default/assets/android-icon-168x168.png deleted file mode 100644 index aa88da2a1..000000000 Binary files a/src/themes/default/assets/android-icon-168x168.png and /dev/null differ diff --git a/src/themes/default/assets/android-icon-192x192.png b/src/themes/default/assets/android-icon-192x192.png deleted file mode 100644 index 684955c9d..000000000 Binary files a/src/themes/default/assets/android-icon-192x192.png and /dev/null differ diff --git a/src/themes/default/assets/android-icon-48x48.png b/src/themes/default/assets/android-icon-48x48.png deleted file mode 100644 index aadd50e93..000000000 Binary files a/src/themes/default/assets/android-icon-48x48.png and /dev/null differ diff --git a/src/themes/default/assets/android-icon-512x512.png b/src/themes/default/assets/android-icon-512x512.png deleted file mode 100644 index b04137be4..000000000 Binary files a/src/themes/default/assets/android-icon-512x512.png and /dev/null differ diff --git a/src/themes/default/assets/android-icon-72x72.png b/src/themes/default/assets/android-icon-72x72.png deleted file mode 100644 index a52539ce3..000000000 Binary files a/src/themes/default/assets/android-icon-72x72.png and /dev/null differ diff --git a/src/themes/default/assets/android-icon-96x96.png b/src/themes/default/assets/android-icon-96x96.png deleted file mode 100644 index cad8888d5..000000000 Binary files a/src/themes/default/assets/android-icon-96x96.png and /dev/null differ diff --git a/src/themes/default/assets/apple-touch-icon.png b/src/themes/default/assets/apple-touch-icon.png deleted file mode 100644 index d7d9883c2..000000000 Binary files a/src/themes/default/assets/apple-touch-icon.png and /dev/null differ diff --git a/src/themes/default/assets/apple_splash_1125.png b/src/themes/default/assets/apple_splash_1125.png deleted file mode 100644 index a652e7ba4..000000000 Binary files a/src/themes/default/assets/apple_splash_1125.png and /dev/null differ diff --git a/src/themes/default/assets/apple_splash_1242.png b/src/themes/default/assets/apple_splash_1242.png deleted file mode 100644 index fe83551d4..000000000 Binary files a/src/themes/default/assets/apple_splash_1242.png and /dev/null differ diff --git a/src/themes/default/assets/apple_splash_1536.png b/src/themes/default/assets/apple_splash_1536.png deleted file mode 100644 index 45e1ad20a..000000000 Binary files a/src/themes/default/assets/apple_splash_1536.png and /dev/null differ diff --git a/src/themes/default/assets/apple_splash_1668.png b/src/themes/default/assets/apple_splash_1668.png deleted file mode 100644 index e1a3f1a9d..000000000 Binary files a/src/themes/default/assets/apple_splash_1668.png and /dev/null differ diff --git a/src/themes/default/assets/apple_splash_2048.png b/src/themes/default/assets/apple_splash_2048.png deleted file mode 100644 index 34d4ef2c0..000000000 Binary files a/src/themes/default/assets/apple_splash_2048.png and /dev/null differ diff --git a/src/themes/default/assets/apple_splash_640.png b/src/themes/default/assets/apple_splash_640.png deleted file mode 100644 index 92d222ee9..000000000 Binary files a/src/themes/default/assets/apple_splash_640.png and /dev/null differ diff --git a/src/themes/default/assets/apple_splash_750.png b/src/themes/default/assets/apple_splash_750.png deleted file mode 100644 index 445e473ee..000000000 Binary files a/src/themes/default/assets/apple_splash_750.png and /dev/null differ diff --git a/src/themes/default/assets/ban1.jpg b/src/themes/default/assets/ban1.jpg deleted file mode 100644 index fcc3da778..000000000 Binary files a/src/themes/default/assets/ban1.jpg and /dev/null differ diff --git a/src/themes/default/assets/ban2.jpg b/src/themes/default/assets/ban2.jpg deleted file mode 100644 index 0b51c66e0..000000000 Binary files a/src/themes/default/assets/ban2.jpg and /dev/null differ diff --git a/src/themes/default/assets/ban3.jpg b/src/themes/default/assets/ban3.jpg deleted file mode 100644 index 13607df55..000000000 Binary files a/src/themes/default/assets/ban3.jpg and /dev/null differ diff --git a/src/themes/default/assets/collection.jpg b/src/themes/default/assets/collection.jpg deleted file mode 100644 index e7920940a..000000000 Binary files a/src/themes/default/assets/collection.jpg and /dev/null differ diff --git a/src/themes/default/assets/favicon-16x16.png b/src/themes/default/assets/favicon-16x16.png deleted file mode 100644 index 9071dc96e..000000000 Binary files a/src/themes/default/assets/favicon-16x16.png and /dev/null differ diff --git a/src/themes/default/assets/favicon-32x32.png b/src/themes/default/assets/favicon-32x32.png deleted file mode 100644 index 911ffd3e5..000000000 Binary files a/src/themes/default/assets/favicon-32x32.png and /dev/null differ diff --git a/src/themes/default/assets/fonts/MaterialIcons-Regular.woff b/src/themes/default/assets/fonts/MaterialIcons-Regular.woff deleted file mode 100644 index b648a3eea..000000000 Binary files a/src/themes/default/assets/fonts/MaterialIcons-Regular.woff and /dev/null differ diff --git a/src/themes/default/assets/fonts/MaterialIcons-Regular.woff2 b/src/themes/default/assets/fonts/MaterialIcons-Regular.woff2 deleted file mode 100644 index 9fa211252..000000000 Binary files a/src/themes/default/assets/fonts/MaterialIcons-Regular.woff2 and /dev/null differ diff --git a/src/themes/default/assets/full_width_banner.jpg b/src/themes/default/assets/full_width_banner.jpg deleted file mode 100644 index 040750687..000000000 Binary files a/src/themes/default/assets/full_width_banner.jpg and /dev/null differ diff --git a/src/themes/default/assets/ig/ig01.jpg b/src/themes/default/assets/ig/ig01.jpg deleted file mode 100644 index ee0045b19..000000000 Binary files a/src/themes/default/assets/ig/ig01.jpg and /dev/null differ diff --git a/src/themes/default/assets/ig/ig02.jpg b/src/themes/default/assets/ig/ig02.jpg deleted file mode 100644 index 168ada8f0..000000000 Binary files a/src/themes/default/assets/ig/ig02.jpg and /dev/null differ diff --git a/src/themes/default/assets/ig/ig03.jpg b/src/themes/default/assets/ig/ig03.jpg deleted file mode 100644 index 9cae58b54..000000000 Binary files a/src/themes/default/assets/ig/ig03.jpg and /dev/null differ diff --git a/src/themes/default/assets/ig/ig04.jpg b/src/themes/default/assets/ig/ig04.jpg deleted file mode 100644 index e3be2b3a7..000000000 Binary files a/src/themes/default/assets/ig/ig04.jpg and /dev/null differ diff --git a/src/themes/default/assets/ig/ig05.jpg b/src/themes/default/assets/ig/ig05.jpg deleted file mode 100644 index bb5261a69..000000000 Binary files a/src/themes/default/assets/ig/ig05.jpg and /dev/null differ diff --git a/src/themes/default/assets/ig/ig06.jpg b/src/themes/default/assets/ig/ig06.jpg deleted file mode 100644 index f37bbaacf..000000000 Binary files a/src/themes/default/assets/ig/ig06.jpg and /dev/null differ diff --git a/src/themes/default/assets/logo.png b/src/themes/default/assets/logo.png deleted file mode 100644 index 63222d49e..000000000 Binary files a/src/themes/default/assets/logo.png and /dev/null differ diff --git a/src/themes/default/assets/logo.svg b/src/themes/default/assets/logo.svg deleted file mode 100644 index 3f81016a6..000000000 --- a/src/themes/default/assets/logo.svg +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - -Layer 1 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/themes/default/assets/manifest.json b/src/themes/default/assets/manifest.json deleted file mode 100644 index 3069a0bdc..000000000 --- a/src/themes/default/assets/manifest.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "short_name": "VSF Demo", - "name": "Vue Storefront", - "background_color": "#ffffff", - "display": "standalone", - "theme_color": "#ffffff", - "start_url": "/pwa.html", - "icons": [ - { - "src": "/assets/android-icon-48x48.png", - "type": "image/png", - "sizes": "48x48" - }, - { - "src": "/assets/android-icon-72x72.png", - "type": "image/png", - "sizes": "72x72" - }, - { - "src": "/assets/android-icon-96x96.png", - "type": "image/png", - "sizes": "96x96" - }, - { - "src": "/assets/android-icon-144x144.png", - "type": "image/png", - "sizes": "144x144" - }, - { - "src": "/assets/android-icon-168x168.png", - "type": "image/png", - "sizes": "168x168" - }, - { - "src": "/assets/android-icon-192x192.png", - "type": "image/png", - "sizes": "192x192" - }, - { - "src": "/assets/android-icon-512x512.png", - "type": "image/png", - "sizes": "512x512" - } - ] - } diff --git a/src/themes/default/assets/placeholder.jpg b/src/themes/default/assets/placeholder.jpg deleted file mode 100644 index 2f3d6a901..000000000 Binary files a/src/themes/default/assets/placeholder.jpg and /dev/null differ diff --git a/src/themes/default/assets/placeholder.svg b/src/themes/default/assets/placeholder.svg deleted file mode 100644 index ba821924f..000000000 --- a/src/themes/default/assets/placeholder.svg +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - -Layer 1 - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/themes/default/assets/slide_01.jpg b/src/themes/default/assets/slide_01.jpg deleted file mode 100644 index 2dd2364a0..000000000 Binary files a/src/themes/default/assets/slide_01.jpg and /dev/null differ diff --git a/src/themes/default/assets/slide_02.jpg b/src/themes/default/assets/slide_02.jpg deleted file mode 100644 index fb1ad962e..000000000 Binary files a/src/themes/default/assets/slide_02.jpg and /dev/null differ diff --git a/src/themes/default/assets/slide_03.jpg b/src/themes/default/assets/slide_03.jpg deleted file mode 100644 index 447ff9620..000000000 Binary files a/src/themes/default/assets/slide_03.jpg and /dev/null differ diff --git a/src/themes/default/components/core/AddToCart.vue b/src/themes/default/components/core/AddToCart.vue deleted file mode 100644 index 24ff8c7a7..000000000 --- a/src/themes/default/components/core/AddToCart.vue +++ /dev/null @@ -1,60 +0,0 @@ - - - diff --git a/src/themes/default/components/core/BackToTop.vue b/src/themes/default/components/core/BackToTop.vue deleted file mode 100644 index 26346bc80..000000000 --- a/src/themes/default/components/core/BackToTop.vue +++ /dev/null @@ -1,125 +0,0 @@ - - - - diff --git a/src/themes/default/components/core/Breadcrumbs.vue b/src/themes/default/components/core/Breadcrumbs.vue deleted file mode 100644 index 252e2983a..000000000 --- a/src/themes/default/components/core/Breadcrumbs.vue +++ /dev/null @@ -1,20 +0,0 @@ - - - diff --git a/src/themes/default/components/core/ColorSelector.vue b/src/themes/default/components/core/ColorSelector.vue deleted file mode 100644 index 7b9669c04..000000000 --- a/src/themes/default/components/core/ColorSelector.vue +++ /dev/null @@ -1,60 +0,0 @@ - - - - - diff --git a/src/themes/default/components/core/Columns.vue b/src/themes/default/components/core/Columns.vue deleted file mode 100644 index ae78451be..000000000 --- a/src/themes/default/components/core/Columns.vue +++ /dev/null @@ -1,65 +0,0 @@ - - - - diff --git a/src/themes/default/components/core/CookieNotification.vue b/src/themes/default/components/core/CookieNotification.vue deleted file mode 100644 index 68ad22f71..000000000 --- a/src/themes/default/components/core/CookieNotification.vue +++ /dev/null @@ -1,93 +0,0 @@ - - - - - diff --git a/src/themes/default/components/core/GenericSelector.vue b/src/themes/default/components/core/GenericSelector.vue deleted file mode 100644 index 0a9ffde70..000000000 --- a/src/themes/default/components/core/GenericSelector.vue +++ /dev/null @@ -1,54 +0,0 @@ - - - - - diff --git a/src/themes/default/components/core/LanguageSwitcher.vue b/src/themes/default/components/core/LanguageSwitcher.vue deleted file mode 100644 index bf8a60ede..000000000 --- a/src/themes/default/components/core/LanguageSwitcher.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - - - diff --git a/src/themes/default/components/core/Loader.vue b/src/themes/default/components/core/Loader.vue deleted file mode 100644 index 548b3bff0..000000000 --- a/src/themes/default/components/core/Loader.vue +++ /dev/null @@ -1,122 +0,0 @@ - - - - - diff --git a/src/themes/default/components/core/LoaderScoped.vue b/src/themes/default/components/core/LoaderScoped.vue deleted file mode 100644 index 0211ddb6d..000000000 --- a/src/themes/default/components/core/LoaderScoped.vue +++ /dev/null @@ -1,89 +0,0 @@ - - - - - diff --git a/src/themes/default/components/core/Logo.vue b/src/themes/default/components/core/Logo.vue deleted file mode 100644 index a9c774b37..000000000 --- a/src/themes/default/components/core/Logo.vue +++ /dev/null @@ -1,34 +0,0 @@ - - - diff --git a/src/themes/default/components/core/Modal.vue b/src/themes/default/components/core/Modal.vue deleted file mode 100644 index 8167df7af..000000000 --- a/src/themes/default/components/core/Modal.vue +++ /dev/null @@ -1,187 +0,0 @@ - - - - - diff --git a/src/themes/default/components/core/NewsletterPopup.vue b/src/themes/default/components/core/NewsletterPopup.vue deleted file mode 100644 index a96893b7b..000000000 --- a/src/themes/default/components/core/NewsletterPopup.vue +++ /dev/null @@ -1,83 +0,0 @@ - - diff --git a/src/themes/default/components/core/Notification.vue b/src/themes/default/components/core/Notification.vue deleted file mode 100644 index 6f4ab10b4..000000000 --- a/src/themes/default/components/core/Notification.vue +++ /dev/null @@ -1,140 +0,0 @@ - - - - - diff --git a/src/themes/default/components/core/OfflineBadge.vue b/src/themes/default/components/core/OfflineBadge.vue deleted file mode 100644 index 1e1bcb3d4..000000000 --- a/src/themes/default/components/core/OfflineBadge.vue +++ /dev/null @@ -1,33 +0,0 @@ - - - - diff --git a/src/themes/default/components/core/Overlay.vue b/src/themes/default/components/core/Overlay.vue deleted file mode 100644 index 17c6d90ff..000000000 --- a/src/themes/default/components/core/Overlay.vue +++ /dev/null @@ -1,37 +0,0 @@ - - - - - diff --git a/src/themes/default/components/core/PriceSelector.vue b/src/themes/default/components/core/PriceSelector.vue deleted file mode 100644 index c9f804c9c..000000000 --- a/src/themes/default/components/core/PriceSelector.vue +++ /dev/null @@ -1,52 +0,0 @@ - - - - - diff --git a/src/themes/default/components/core/ProductAttribute.vue b/src/themes/default/components/core/ProductAttribute.vue deleted file mode 100644 index a3a2b582f..000000000 --- a/src/themes/default/components/core/ProductAttribute.vue +++ /dev/null @@ -1,14 +0,0 @@ - - - diff --git a/src/themes/default/components/core/ProductBundleOption.vue b/src/themes/default/components/core/ProductBundleOption.vue deleted file mode 100644 index 51bd8f847..000000000 --- a/src/themes/default/components/core/ProductBundleOption.vue +++ /dev/null @@ -1,155 +0,0 @@ - - - - - diff --git a/src/themes/default/components/core/ProductBundleOptions.vue b/src/themes/default/components/core/ProductBundleOptions.vue deleted file mode 100644 index d36f910e3..000000000 --- a/src/themes/default/components/core/ProductBundleOptions.vue +++ /dev/null @@ -1,21 +0,0 @@ - - - - diff --git a/src/themes/default/components/core/ProductCustomOptions.vue b/src/themes/default/components/core/ProductCustomOptions.vue deleted file mode 100644 index a6eea550c..000000000 --- a/src/themes/default/components/core/ProductCustomOptions.vue +++ /dev/null @@ -1,161 +0,0 @@ - - - - diff --git a/src/themes/default/components/core/ProductGallery.vue b/src/themes/default/components/core/ProductGallery.vue deleted file mode 100644 index cd928ea53..000000000 --- a/src/themes/default/components/core/ProductGallery.vue +++ /dev/null @@ -1,103 +0,0 @@ - - - - - diff --git a/src/themes/default/components/core/ProductGalleryCarousel.vue b/src/themes/default/components/core/ProductGalleryCarousel.vue deleted file mode 100644 index 88ca1bbe9..000000000 --- a/src/themes/default/components/core/ProductGalleryCarousel.vue +++ /dev/null @@ -1,216 +0,0 @@ - - - - - - - diff --git a/src/themes/default/components/core/ProductGalleryOverlay.vue b/src/themes/default/components/core/ProductGalleryOverlay.vue deleted file mode 100644 index d6154f283..000000000 --- a/src/themes/default/components/core/ProductGalleryOverlay.vue +++ /dev/null @@ -1,77 +0,0 @@ - - - diff --git a/src/themes/default/components/core/ProductGalleryZoomCarousel.vue b/src/themes/default/components/core/ProductGalleryZoomCarousel.vue deleted file mode 100644 index 0a93e61fc..000000000 --- a/src/themes/default/components/core/ProductGalleryZoomCarousel.vue +++ /dev/null @@ -1,227 +0,0 @@ - - - - - - - diff --git a/src/themes/default/components/core/ProductImage.vue b/src/themes/default/components/core/ProductImage.vue deleted file mode 100644 index 6fd56e67d..000000000 --- a/src/themes/default/components/core/ProductImage.vue +++ /dev/null @@ -1,131 +0,0 @@ - - - - - diff --git a/src/themes/default/components/core/ProductLinks.vue b/src/themes/default/components/core/ProductLinks.vue deleted file mode 100644 index 317a81cd4..000000000 --- a/src/themes/default/components/core/ProductLinks.vue +++ /dev/null @@ -1,60 +0,0 @@ - - - - - diff --git a/src/themes/default/components/core/ProductListing.vue b/src/themes/default/components/core/ProductListing.vue deleted file mode 100644 index e2d752a5f..000000000 --- a/src/themes/default/components/core/ProductListing.vue +++ /dev/null @@ -1,44 +0,0 @@ - - - diff --git a/src/themes/default/components/core/ProductPrice.vue b/src/themes/default/components/core/ProductPrice.vue deleted file mode 100644 index e9db8b7ae..000000000 --- a/src/themes/default/components/core/ProductPrice.vue +++ /dev/null @@ -1,105 +0,0 @@ - - - diff --git a/src/themes/default/components/core/ProductQuantity.vue b/src/themes/default/components/core/ProductQuantity.vue deleted file mode 100644 index 53ef931cf..000000000 --- a/src/themes/default/components/core/ProductQuantity.vue +++ /dev/null @@ -1,117 +0,0 @@ - - - - diff --git a/src/themes/default/components/core/ProductTile.vue b/src/themes/default/components/core/ProductTile.vue deleted file mode 100644 index 5dc386eff..000000000 --- a/src/themes/default/components/core/ProductTile.vue +++ /dev/null @@ -1,242 +0,0 @@ - - - - - diff --git a/src/themes/default/components/core/ProductVideo.vue b/src/themes/default/components/core/ProductVideo.vue deleted file mode 100644 index d3cc4267a..000000000 --- a/src/themes/default/components/core/ProductVideo.vue +++ /dev/null @@ -1,77 +0,0 @@ -