diff --git a/config/default.json b/config/default.json index 3e7ed38ce2..d1803e09c7 100644 --- a/config/default.json +++ b/config/default.json @@ -17,7 +17,7 @@ "elasticCacheQuota": 4096 }, "seo": { - "useUrlDispatcher": false + "useUrlDispatcher": true }, "console": { "showErrorOnProduction" : true, @@ -144,7 +144,7 @@ "twoStageCaching": true, "optimizeShoppingCart": true, "category": { - "includeFields": [ "id", "*.children_data.id", "*.id", "children_count", "sku", "name", "is_active", "parent_id", "level", "url_key", "product_count", "path"], + "includeFields": [ "id", "*.children_data.id", "*.id", "children_count", "sku", "name", "is_active", "parent_id", "level", "url_key", "url_path", "product_count", "path"], "excludeFields": [ "sgn" ], "categoriesRootCategorylId": 2, "categoriesDynamicPrefetchLevel": 2, @@ -155,11 +155,11 @@ }, "productList": { "sort": "", - "includeFields": [ "type_id", "sku", "product_links", "tax_class_id", "special_price", "special_to_date", "special_from_date", "name", "price", "priceInclTax", "originalPriceInclTax", "originalPrice", "specialPriceInclTax", "id", "image", "sale", "new", "url_key", "status", "tier_prices", "configurable_children.sku", "configurable_children.price", "configurable_children.special_price", "configurable_children.priceInclTax", "configurable_children.specialPriceInclTax", "configurable_children.originalPrice", "configurable_children.originalPriceInclTax" ], + "includeFields": [ "type_id", "sku", "product_links", "tax_class_id", "special_price", "special_to_date", "special_from_date", "name", "price", "priceInclTax", "originalPriceInclTax", "originalPrice", "specialPriceInclTax", "id", "image", "sale", "new", "url_path", "url_key", "status", "tier_prices", "configurable_children.sku", "configurable_children.price", "configurable_children.special_price", "configurable_children.priceInclTax", "configurable_children.specialPriceInclTax", "configurable_children.originalPrice", "configurable_children.originalPriceInclTax" ], "excludeFields": [ "description", "configurable_options", "sgn", "*.sgn", "msrp_display_actual_price_type", "*.msrp_display_actual_price_type", "required_options" ] }, "productListWithChildren": { - "includeFields": [ "type_id", "sku", "name", "tax_class_id", "special_price", "special_to_date", "special_from_date", "price", "priceInclTax", "originalPriceInclTax", "originalPrice", "specialPriceInclTax", "id", "image", "sale", "new", "configurable_children.image", "configurable_children.sku", "configurable_children.price", "configurable_children.special_price", "configurable_children.priceInclTax", "configurable_children.specialPriceInclTax", "configurable_children.originalPrice", "configurable_children.originalPriceInclTax", "configurable_children.color", "configurable_children.size", "configurable_children.id", "configurable_children.tier_prices", "product_links", "url_key", "status", "tier_prices"], + "includeFields": [ "type_id", "sku", "name", "tax_class_id", "special_price", "special_to_date", "special_from_date", "price", "priceInclTax", "originalPriceInclTax", "originalPrice", "specialPriceInclTax", "id", "image", "sale", "new", "configurable_children.image", "configurable_children.sku", "configurable_children.price", "configurable_children.special_price", "configurable_children.priceInclTax", "configurable_children.specialPriceInclTax", "configurable_children.originalPrice", "configurable_children.originalPriceInclTax", "configurable_children.color", "configurable_children.size", "configurable_children.id", "configurable_children.tier_prices", "product_links", "url_path", "url_key", "status", "tier_prices"], "excludeFields": [ "description", "sgn", "*.sgn", "msrp_display_actual_price_type", "*.msrp_display_actual_price_type", "required_options"] }, "review": { diff --git a/core/app.ts b/core/app.ts index d1b8f6a2be..204e46b7b7 100755 --- a/core/app.ts +++ b/core/app.ts @@ -65,7 +65,7 @@ const createApp = async (ssrContext, config): Promise<{app: Vue, router: VueRou // sync router with vuex 'router' store sync(store, router) // TODO: Don't mutate the state directly, use mutation instead - store.state.version = '1.7.0' + store.state.version = '1.8.2' store.state.config = config store.state.__DEMO_MODE__ = (config.demomode === true) ? true : false if(ssrContext) Vue.prototype.$ssrRequestContext = ssrContext diff --git a/core/build/webpack.base.config.ts b/core/build/webpack.base.config.ts index b37219f6d3..10eaa243be 100644 --- a/core/build/webpack.base.config.ts +++ b/core/build/webpack.base.config.ts @@ -78,6 +78,7 @@ export default { inject: isProd == false }) ], + devtool: 'source-map', entry: { app: ['babel-polyfill', './core/client-entry.ts'] }, diff --git a/core/build/webpack.client.config.ts b/core/build/webpack.client.config.ts index 5876981d6f..39b9a6f09c 100644 --- a/core/build/webpack.client.config.ts +++ b/core/build/webpack.client.config.ts @@ -32,7 +32,7 @@ const config = merge(base, { 'process.env.VUE_ENV': '"client"' }), new VueSSRClientPlugin() - ] + ], }) export default config; diff --git a/core/build/webpack.prod.client.config.ts b/core/build/webpack.prod.client.config.ts index 21db2a7577..8256f9f03b 100644 --- a/core/build/webpack.prod.client.config.ts +++ b/core/build/webpack.prod.client.config.ts @@ -7,6 +7,7 @@ const extendedConfig = require(path.join(themeRoot, '/webpack.config.js')) const prodClientConfig = merge(baseClientConfig, { mode: 'production', + devtool: 'nosources-source-map', plugins: [ ] }) diff --git a/core/build/webpack.prod.server.config.ts b/core/build/webpack.prod.server.config.ts index ea6879bebf..350e3d9a9d 100644 --- a/core/build/webpack.prod.server.config.ts +++ b/core/build/webpack.prod.server.config.ts @@ -8,6 +8,7 @@ const extendedConfig = require(path.join(themeRoot, '/webpack.config.js')) export default extendedConfig(baseServerConfig, { mode: 'production', + devtool: 'nosources-source-map', isClient: false, isDev: false }) diff --git a/core/client-entry.ts b/core/client-entry.ts index 63d131c53c..4c45414515 100755 --- a/core/client-entry.ts +++ b/core/client-entry.ts @@ -68,9 +68,14 @@ const invokeClientEntry = async () => { _commonErrorHandler(err, next) }) } - + let _appMounted = false router.onReady(() => { - router.beforeResolve((to, from, next) => { + router.afterEach((to, from) => { + if (config.seo.useUrlDispatcher && !_appMounted) { // hydrate after each other request + app.$mount('#app') + } + }) + router.beforeResolve((to, from, next) => { // this is NOT CALLED after SSR request as no component is being resolved client side if (!from.name) return next() // do not resolve asyncData on server render - already been done if (Vue.prototype.$ssrRequestContext) Vue.prototype.$ssrRequestContext.output.cacheTags = new Set() const matched = router.getMatchedComponents(to) @@ -89,9 +94,9 @@ const invokeClientEntry = async () => { } } let diffed = false - const activated = matched.filter((c, i) => { + const activated = config.seo.useUrlDispatcher ? matched : (matched.filter((c, i) => { return diffed || (diffed = (prevMatched[i] !== c)) - }) + })) if (!activated.length) { return next() } @@ -112,7 +117,10 @@ const invokeClientEntry = async () => { } })) }) - app.$mount('#app') + if (!config.seo.useUrlDispatcher) { // if UrlDispatcher is enabled, we're mounting the app in `beforeResolve` - shortly after the component is loaded + app.$mount('#app') + _appMounted = true + } }) /* * serial executes Promises sequentially. diff --git a/core/helpers/index.ts b/core/helpers/index.ts index d18f70a954..bd4a3f39bc 100644 --- a/core/helpers/index.ts +++ b/core/helpers/index.ts @@ -2,6 +2,10 @@ import rootStore from '@vue-storefront/store' import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' import { remove as removeAccents } from 'remove-accents' import { Logger } from '@vue-storefront/core/lib/logger' +import chain from 'lodash-es/chain' +import partial from 'lodash-es/partial' +import split from 'lodash-es/split' +import { type } from 'os'; /** * Create slugify -> "create-slugify" permalink of text @@ -19,7 +23,34 @@ export function slugify (text) { .replace(/[^\w-]+/g, '') // Remove all non-word chars .replace(/--+/g, '-') // Replace multiple - with single - } +/** + * + * @param obj Build URL query string + */ +export function buildURLQuery (obj, removeNullValues = true) { + let pairs = Object.entries(obj) + if (removeNullValues) pairs = pairs.filter(pair => { + if (pair.length == 2 && !pair[1]) return false + return true + }) + return (pairs.map(pair => { + return pair.map(encodeURIComponent).join('=') + }).join('&')) +} +export function parseURLQuery (queryString) { + if (queryString === null || typeof queryString !== 'string') return {} + const params = {} + let queries, temp, i, l + // Split into key/value pairs + queries = queryString.split("&") + // Convert the array of strings into an object + for ( i = 0, l = queries.length; i < l; i++ ) { + temp = queries[i].split('=') + params[temp[0]] = temp[1] + } + return params +} /** * @param relativeUrl * @param width @@ -54,7 +85,7 @@ export function breadCrumbRoutes (categoryPath) { for (let sc of categoryPath) { tmpRts.push({ name: sc.name, - route_link: (rootStore.state.config.products.useShortCatalogUrls ? '/' : '/c/') + sc.slug + route_link: rootStore.state.config.seo.useUrlDispatcher ? ('/' + sc.url_path) : ((rootStore.state.config.products.useShortCatalogUrls ? '/' : '/c/') + sc.slug) }) } diff --git a/core/lib/async-data-loader.ts b/core/lib/async-data-loader.ts index d9f4a30821..e431792d62 100644 --- a/core/lib/async-data-loader.ts +++ b/core/lib/async-data-loader.ts @@ -36,13 +36,13 @@ const AsyncDataLoader = { flush : function (actionContext: AsyncDataLoaderActionContext) { if (!actionContext.category) actionContext.category = DEFAULT_ACTION_CATEGORY const actionsToExecute = this.queue.filter(ac => (!ac.category || !actionContext.category) || ac.category === actionContext.category && (!ac.executedAt)).map(ac => { + ac.executedAt = new Date() return ac.execute(actionContext) // function must return Promise }) if (actionsToExecute.length > 0) { Logger.info('Executing data loader actions(' + actionsToExecute.length + ')', 'dataloader')() } - return Promise.all(actionsToExecute).then(results => { - actionsToExecute.map(ac => ac.executedAt = new Date()) + return Promise.all(actionsToExecute).then(results => { return results }) } diff --git a/core/lib/logger.ts b/core/lib/logger.ts index 22830e8d65..6e7bc6dea7 100644 --- a/core/lib/logger.ts +++ b/core/lib/logger.ts @@ -1,6 +1,5 @@ import { isServer } from '@vue-storefront/core/helpers' import buildTimeConfig from 'config' - const bgColorStyle = (color) => `color: white; background: ${color}; padding: 4px; font-weight: bold; font-size: 0.8em'` /** VS message logger. By default works only on dev mode */ @@ -34,6 +33,22 @@ class Logger this.isProduction = process.env.NODE_ENV === 'production'; } + /** + * Convert message to string - as it may be object, array either primitive + * @param payload + */ + convertToString (payload: any) { + if (typeof payload === 'string' || typeof payload === 'boolean' || typeof payload === 'number') return payload + if (Array.isArray(payload)) return JSON.stringify(payload) + if (typeof payload === 'object') { + if (payload.hasOwnProperty('message')) { + return payload.message + } else { + return JSON.stringify(payload) + } + } + } + /** * Check if method can print into console * @@ -65,12 +80,12 @@ class Logger * @param tag short tag specifying area where message was spawned (eg. cart, sync, module) * @param context meaningful data related to this message */ - debug (message: string, tag: string = null, context: any = null) : () => void { + debug (message: any, tag: string = null, context: any = null) : () => void { if (!isServer && this.canPrint('debug')) { if (tag) { - return console.debug.bind(window.console, '%cVSF%c %c' + tag +'%c ' + message, bgColorStyle('grey'), 'color: inherit', bgColorStyle('gray'), 'font-weight: normal', context); + return console.debug.bind(window.console, '%cVSF%c %c' + tag +'%c ' + this.convertToString(message), bgColorStyle('grey'), 'color: inherit', bgColorStyle('gray'), 'font-weight: normal', context); } else { - return console.debug.bind(window.console, '%cVSF%c ' + message, bgColorStyle('white'), 'font-weight: normal', context); + return console.debug.bind(window.console, '%cVSF%c ' + this.convertToString(message), bgColorStyle('grey'), 'font-weight: normal', context); } } else { return function () {} @@ -85,7 +100,7 @@ class Logger * @param tag short tag specifying area where message was spawned (eg. cart, sync, module) * @param context meaningful data related to this message */ - log (message: string, tag: string = null, context: any = null) : () => void { + log (message: any, tag: string = null, context: any = null) : () => void { return this.info(message, tag, context); } @@ -97,12 +112,12 @@ class Logger * @param tag short tag specifying area where message was spawned (eg. cart, sync, module) * @param context meaningful data related to this message */ - info (message: string, tag: string = null, context: any = null) : () => void { + info (message: any, tag: string = null, context: any = null) : () => void { if (!isServer && this.canPrint('info')) { if (tag) { - return console.log.bind(window.console, '%cVSF%c %c' + tag +'%c ' + message, bgColorStyle('green'), 'color: inherit', bgColorStyle('gray'), 'font-weight: bold', context); + return console.log.bind(window.console, '%cVSF%c %c' + tag +'%c ' + this.convertToString(message), bgColorStyle('green'), 'color: inherit', bgColorStyle('gray'), 'font-weight: bold', context); } else { - return console.log.bind(window.console, '%cVSF%c ' + message, bgColorStyle('green'), 'font-weight: bold', context); + return console.log.bind(window.console, '%cVSF%c ' + this.convertToString(message), bgColorStyle('green'), 'font-weight: bold', context); } } else { return function () {} @@ -117,12 +132,16 @@ class Logger * @param tag short tag specifying area where message was spawned (eg. cart, sync, module) * @param context meaningful data related to this message */ - warn (message: string, tag: string = null, context: any = null) : () => void { - if (!isServer && this.canPrint('warn')) { - if (tag) { - return console.warn.bind(window.console, '%cVSF%c %c' + tag +'%c ' + message, bgColorStyle('orange'), 'color: inherit', bgColorStyle('gray'), 'font-weight: bold', context); + warn (message: any, tag: string = null, context: any = null) : () => void { + if (this.canPrint('warn')) { + if (!isServer) { + if (tag) { + return console.warn.bind(window.console, '%cVSF%c %c' + tag +'%c ' + this.convertToString(message), bgColorStyle('orange'), 'color: inherit', bgColorStyle('gray'), 'font-weight: bold', context); + } else { + return console.warn.bind(window.console, '%cVSF%c ' + this.convertToString(message), bgColorStyle('orange'), 'font-weight: bold', context); + } } else { - return console.warn.bind(window.console, '%cVSF%c ' + message, bgColorStyle('orange'), 'font-weight: bold', context); + return console.warn.bind(console, message, context); } } else { return function () {} @@ -137,12 +156,16 @@ class Logger * @param tag short tag specifying area where message was spawned (eg. cart, sync, module) * @param context meaningful data related to this message */ - error (message: string, tag: string = null, context: any = null) : () => void { - if (!isServer && this.canPrint('error')) { - if (tag) { - return console.error.bind(window.console, '%cVSF%c %c' + tag +'%c ' + message, bgColorStyle('red'), 'color: inherit', bgColorStyle('gray'), 'font-weight: bold', context); + error (message: any, tag: string = null, context: any = null) : () => void { + if (this.canPrint('error')) { // we should display SSR errors for better monitoring + error handling + if (!isServer) { + if (tag) { + return console.error.bind(window.console, '%cVSF%c %c' + tag +'%c ' + this.convertToString(message), bgColorStyle('red'), 'color: inherit', bgColorStyle('gray'), 'font-weight: bold', context); + } else { + return console.error.bind(window.console, '%cVSF%c ' + this.convertToString(message), bgColorStyle('red'), 'font-weight: bold', context); + } } else { - return console.error.bind(window.console, '%cVSF%c ' + message, bgColorStyle('red'), 'font-weight: bold', context); + return console.error.bind(console, message, context); } } else { return function () {} diff --git a/core/lib/multistore.ts b/core/lib/multistore.ts index b52bcba9e2..ec2b6b95db 100644 --- a/core/lib/multistore.ts +++ b/core/lib/multistore.ts @@ -3,6 +3,7 @@ import { loadLanguageAsync } from '@vue-storefront/i18n' import { initializeSyncTaskStorage } from './sync/task' import Vue from 'vue' import { Route } from 'vue-router' +import { buildURLQuery } from '@vue-storefront/core/helpers' export interface StoreView { storeCode: string, @@ -92,13 +93,16 @@ export function adjustMultistoreApiUrl (url: string) : string { } export function localizedRoute (routeObj: Route | string, storeCode: string) { + if (rootStore.state.config.seo.useUrlDispatcher) { + if (routeObj && typeof routeObj === 'object' && routeObj.fullPath) return localizedDispatcherRoute(Object.assign({}, routeObj, { params: null }), storeCode) + } if (storeCode && routeObj && rootStore.state.config.defaultStoreCode !== storeCode) { if (typeof routeObj === 'object') { if (routeObj.name) { routeObj.name = storeCode + '-' + routeObj.name } if (routeObj.path) { - routeObj.path = '/' + storeCode + '/' + routeObj.path.slice(1) + routeObj.path = '/' + storeCode + '/' + (routeObj.path.startsWith('/') ? routeObj.path.slice(1) : routeObj.path) } } else { return '/' + storeCode + routeObj @@ -107,6 +111,17 @@ export function localizedRoute (routeObj: Route | string, storeCode: string) { return routeObj } +export function localizedDispatcherRoute (routeObj: Route | string, storeCode: string) { + if (typeof routeObj === 'string') { + return '/' + storeCode + routeObj + } + if (routeObj && typeof routeObj === 'object' && routeObj.fullPath) { // case of using dispatcher + return '/' + ((rootStore.state.config.defaultStoreCode !== storeCode) ? (storeCode + '/') : '') + routeObj.fullPath + (routeObj.params ? ('?' + buildURLQuery(routeObj.params)) : '') + } else { + return routeObj + } +} + export function setupMultistoreRoutes (config, router, routes) { if (config.storeViews.mapStoreUrlsFor.length > 0 && config.storeViews.multistore === true) { for (let storeCode of config.storeViews.mapStoreUrlsFor) { diff --git a/core/lib/search/adapter/api/searchAdapter.ts b/core/lib/search/adapter/api/searchAdapter.ts index 7ba0ee1a95..235652bcde 100644 --- a/core/lib/search/adapter/api/searchAdapter.ts +++ b/core/lib/search/adapter/api/searchAdapter.ts @@ -2,7 +2,7 @@ import map from 'lodash-es/map' import rootStore from '@vue-storefront/store' import { prepareElasticsearchQueryBody } from './elasticsearchQuery' import fetch from 'isomorphic-fetch' -import { slugify } from '@vue-storefront/core/helpers' +import { slugify, buildURLQuery } from '@vue-storefront/core/helpers' import { currentStoreView, prepareStoreView } from '../../../multistore' import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' import HttpQuery from '@vue-storefront/core/types/search/HttpQuery' @@ -20,9 +20,6 @@ export class SearchAdapter { if (!this.entities[Request.type]) { throw new Error('No entity type registered for ' + Request.type ) } - - const buildURLQuery = obj => Object.entries(obj).map(pair => pair.map(encodeURIComponent).join('=')).join('&') - let ElasticsearchQueryBody = {} if (Request.searchQuery instanceof SearchQuery) { ElasticsearchQueryBody = await prepareElasticsearchQueryBody(Request.searchQuery) @@ -94,7 +91,7 @@ export class SearchAdapter { if (resp.hasOwnProperty('hits')) { return { items: map(resp.hits.hits, hit => { - return Object.assign(hit._source, { _score: hit._score, slug: (hit._source.hasOwnProperty('url_key') && rootStore.state.config.products.useMagentoUrlKeys) ? hit._source.url_key : (hit._source.hasOwnProperty('name') ? slugify(hit._source.name) + '-' + hit._source.id : '') }) // TODO: assign slugs server side + return Object.assign(hit._source, { _score: hit._score, slug: hit._source.hasOwnProperty('slug') ? hit._source.slug : ((hit._source.hasOwnProperty('url_key') && rootStore.state.config.products.useMagentoUrlKeys) ? hit._source.url_key : (hit._source.hasOwnProperty('name') ? slugify(hit._source.name) + '-' + hit._source.id : '')) }) // TODO: assign slugs server side }), // TODO: add scoring information total: resp.hits.total, start: start, diff --git a/core/lib/search/adapter/graphql/processor/processType.ts b/core/lib/search/adapter/graphql/processor/processType.ts index c63c1a0545..91ac5f9004 100644 --- a/core/lib/search/adapter/graphql/processor/processType.ts +++ b/core/lib/search/adapter/graphql/processor/processType.ts @@ -8,9 +8,9 @@ export function processESResponseType (resp, start, size): SearchResponse { items: map(resp.hits.hits, hit => { return Object.assign(hit._source, { _score: hit._score, - slug: (hit._source.hasOwnProperty('url_key') && rootStore.state.config.products.useMagentoUrlKeys) + slug: hit._source.hasOwnProperty('slug') ? hit._source.slug : ((hit._source.hasOwnProperty('url_key') && rootStore.state.config.products.useMagentoUrlKeys) ? hit._source.url_key - : (hit._source.hasOwnProperty('name') ? slugify(hit._source.name) + '-' + hit._source.id : '') + : (hit._source.hasOwnProperty('name') ? slugify(hit._source.name) + '-' + hit._source.id : '')) }) // TODO: assign slugs server side }), // TODO: add scoring information total: resp.hits.total, @@ -30,10 +30,10 @@ export function processProductsType (resp, start, size): SearchResponse { options['_score'] = item._score delete item._score } - options['slug'] = (item.hasOwnProperty('url_key') && + options['slug'] = item.hasOwnProperty('slug') ? item.slug : ((item.hasOwnProperty('url_key') && rootStore.state.config.products.useMagentoUrlKeys) ? item.url_key : (item.hasOwnProperty('name') - ? slugify(item.name) + '-' + item.id : '') + ? slugify(item.name) + '-' + item.id : '')) return Object.assign(item, options) // TODO: assign slugs server side }), // TODO: add scoring information diff --git a/core/lib/sync/index.ts b/core/lib/sync/index.ts index c72dca54d4..5b365efa4e 100644 --- a/core/lib/sync/index.ts +++ b/core/lib/sync/index.ts @@ -6,6 +6,7 @@ import { execute as taskExecute, _prepareTask } from './task' import * as localForage from 'localforage' import UniversalStorage from '@vue-storefront/store/lib/storage' import { currentStoreView } from '../multistore' +import { isServer } from '@vue-storefront/core/helpers' /** Syncs given task. If user is offline requiest will be sent to the server after restored connection */ function queue (task) { @@ -41,7 +42,7 @@ function execute (task) { // not offline task driver: localForage[rootStore.state.config.localForage.defaultDrivers['carts']] })) return new Promise((resolve, reject) => { - if (Vue.prototype.$isServer) { + if (isServer) { taskExecute(task, null, null).then((result) => { resolve(result) }).catch(err => { diff --git a/core/mixins/multistore.js b/core/mixins/multistore.js index c25f3afdb5..b3adb383a0 100644 --- a/core/mixins/multistore.js +++ b/core/mixins/multistore.js @@ -1,4 +1,4 @@ -import { localizedRoute as localizedRouteHelper, currentStoreView } from '@vue-storefront/core/lib/multistore' +import { localizedRoute as localizedRouteHelper, localizedDispatcherRoute as localizedDispatcherRouteHelper, currentStoreView } from '@vue-storefront/core/lib/multistore' export const multistore = { methods: { @@ -11,6 +11,16 @@ export const multistore = { localizedRoute (routeObj) { const storeView = currentStoreView() return localizedRouteHelper(routeObj, storeView.storeCode) + }, + /** + * Return localized route params for URL Dispatcher + * @param {String} relativeUrl + * @param {Int} width + * @param {Int} height + */ + localizedDispatcherRoute (routeObj) { + const storeView = currentStoreView() + return localizedDispatcherRouteHelper(routeObj, storeView.storeCode) } } } diff --git a/core/modules/breadcrumbs/helpers/index.ts b/core/modules/breadcrumbs/helpers/index.ts index 294e2fc19f..05d4f8f22a 100644 --- a/core/modules/breadcrumbs/helpers/index.ts +++ b/core/modules/breadcrumbs/helpers/index.ts @@ -7,7 +7,7 @@ export function parseCategoryPath (categoryPath) { for (let sc of categoryPath) { tmpRts.push({ name: sc.name, - route_link: (rootStore.state.config.products.useShortCatalogUrls ? '/' : '/c/') + sc.slug + route_link: rootStore.state.config.seo.useUrlDispatcher ? sc.url_path : ((rootStore.state.config.products.useShortCatalogUrls ? '/' : '/c/') + sc.slug) }) } diff --git a/core/modules/cart/store/actions.ts b/core/modules/cart/store/actions.ts index 938f73faaa..c99fd345e6 100644 --- a/core/modules/cart/store/actions.ts +++ b/core/modules/cart/store/actions.ts @@ -14,6 +14,7 @@ import { Logger } from '@vue-storefront/core/lib/logger' import { TaskQueue } from '@vue-storefront/core/lib/sync' import { router } from '@vue-storefront/core/app' import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' +import { isServer } from '@vue-storefront/core/helpers' const CART_PULL_INTERVAL_MS = 2000 const CART_CREATE_INTERVAL_MS = 1000 @@ -56,7 +57,7 @@ const actions: ActionTree = { context.commit(types.CART_SAVE) }, serverPull (context, { forceClientState = false, dryRun = false }) { // pull current cart FROM the server - if (rootStore.state.config.cart.synchronize && !Vue.prototype.$isServer) { + if (rootStore.state.config.cart.synchronize && !isServer) { const newItemsHash = sha3_224(JSON.stringify({ items: context.state.cartItems, token: context.state.cartServerToken })) if ((Date.now() - context.state.cartServerPullAt) >= CART_PULL_INTERVAL_MS || (newItemsHash !== context.state.cartItemsHash)) { context.state.cartServerPullAt = Date.now() @@ -91,7 +92,7 @@ const actions: ActionTree = { } }, serverTotals (context, { forceClientState = false }) { // pull current cart FROM the server - if (rootStore.state.config.cart.synchronize_totals && !Vue.prototype.$isServer) { + if (rootStore.state.config.cart.synchronize_totals && !isServer) { if ((Date.now() - context.state.cartServerTotalsAt) >= CART_TOTALS_INTERVAL_MS) { context.state.cartServerPullAt = Date.now() TaskQueue.execute({ url: rootStore.state.config.cart.totals_endpoint, // sync the cart @@ -110,7 +111,7 @@ const actions: ActionTree = { } }, serverCreate (context, { guestCart = false }) { - if (rootStore.state.config.cart.synchronize && !Vue.prototype.$isServer) { + if (rootStore.state.config.cart.synchronize && !isServer) { if ((Date.now() - context.state.cartServerCreatedAt) >= CART_CREATE_INTERVAL_MS) { const task = { url: guestCart ? rootStore.state.config.cart.create_endpoint.replace('{{token}}', '') : rootStore.state.config.cart.create_endpoint, // sync the cart payload: { @@ -174,7 +175,7 @@ const actions: ActionTree = { }, load (context) { return new Promise((resolve, reject) => { - if (Vue.prototype.$isServer) return + if (isServer) return const commit = context.commit const state = context.state diff --git a/core/modules/catalog/components/ProductTile.ts b/core/modules/catalog/components/ProductTile.ts index 566feb9d09..31887070dc 100644 --- a/core/modules/catalog/components/ProductTile.ts +++ b/core/modules/catalog/components/ProductTile.ts @@ -15,6 +15,25 @@ export const ProductTile = { } }, computed: { + productLink () { + return ((this.$store.state.config.seo.useUrlDispatcher) ? + this.localizedDispatcherRoute({ + fullPath: this.$store.state.config.seo.useUrlDispatcher ? this.product.url_path : null, + params: { + childSku: this.product.sku === this.product.parentSku ? null : this.product.sku + } + }) + : + this.localizedRoute({ + name: this.product.type_id + '-product', + params: { + parentSku: this.product.parentSku ? this.product.parentSku : this.product.sku, + slug: this.product.slug, + childSku: this.product.sku + } + }) + ) + }, thumbnail () { // todo: play with the image based on category page filters - eg. when 'red' color is chosen, the image is going to be 'red' let thumbnail = productThumbnailPath(this.product) diff --git a/core/modules/catalog/helpers/index.ts b/core/modules/catalog/helpers/index.ts index 23ceac4308..24ad81f83d 100644 --- a/core/modules/catalog/helpers/index.ts +++ b/core/modules/catalog/helpers/index.ts @@ -13,6 +13,7 @@ import i18n from '@vue-storefront/i18n' import { currentStoreView } from '@vue-storefront/core/lib/multistore' import { getThumbnailPath } from '@vue-storefront/core/helpers' import { Logger } from '@vue-storefront/core/lib/logger' +import { isServer } from '@vue-storefront/core/helpers' function _filterRootProductByStockitem (context, stockItem, product, errorCallback) { if (stockItem) { @@ -235,7 +236,7 @@ export function doPlatformPricesSync (products) { } else { // empty list of products resolve(products) } - if (!rootStore.state.config.products.waitForPlatformSync && !Vue.prototype.$isServer) { + if (!rootStore.state.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 diff --git a/core/modules/catalog/store/category/actions.ts b/core/modules/catalog/store/category/actions.ts index b73ef7323e..307ac26709 100644 --- a/core/modules/catalog/store/category/actions.ts +++ b/core/modules/catalog/store/category/actions.ts @@ -14,6 +14,7 @@ import CategoryState from '../../types/CategoryState' import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' import { currentStoreView } from '@vue-storefront/core/lib/multistore' import { Logger } from '@vue-storefront/core/lib/logger' +import { isServer } from '@vue-storefront/core/helpers' const actions: ActionTree = { @@ -170,7 +171,7 @@ const actions: ActionTree = { } } if (!foundInLocalCache) { - if (skipCache || Vue.prototype.$isServer) { + if (skipCache || isServer) { fetchCat({ key, value }) } else { const catCollection = Vue.prototype.$db.categoriesCollection @@ -197,7 +198,7 @@ const actions: ActionTree = { }) let prefetchGroupProducts = true - if (rootStore.state.config.entities.twoStageCaching && rootStore.state.config.entities.optimize && !Vue.prototype.$isServer && !rootStore.state.twoStageCachingDisabled) { // only client side, only when two stage caching enabled + if (rootStore.state.config.entities.twoStageCaching && rootStore.state.config.entities.optimize && !isServer && !rootStore.state.twoStageCachingDisabled) { // only client side, only when two stage caching enabled includeFields = rootStore.state.config.entities.productListWithChildren.includeFields // we need configurable_children for filters to work excludeFields = rootStore.state.config.entities.productListWithChildren.excludeFields prefetchGroupProducts = false @@ -322,7 +323,7 @@ const actions: ActionTree = { }) }) - if (rootStore.state.config.entities.twoStageCaching && rootStore.state.config.entities.optimize && !Vue.prototype.$isServer && !rootStore.state.twoStageCachingDisabled) { // second stage - request for caching entities + if (rootStore.state.config.entities.twoStageCaching && rootStore.state.config.entities.optimize && !isServer && !rootStore.state.twoStageCachingDisabled) { // second stage - request for caching entities Logger.log('Using two stage caching for performance optimization - executing second stage product caching', 'category') // TODO: in this case we can pre-fetch products in advance getting more products than set by pageSize() rootStore.dispatch('product/list', { query: precachedQuery, diff --git a/core/modules/catalog/store/category/mutations.ts b/core/modules/catalog/store/category/mutations.ts index 4dc7cad990..6bf12cad73 100644 --- a/core/modules/catalog/store/category/mutations.ts +++ b/core/modules/catalog/store/category/mutations.ts @@ -18,11 +18,22 @@ const mutations: MutationTree = { }, [types.CATEGORY_UPD_CATEGORIES] (state, categories) { for (let category of categories.items) { + if (rootStore.state.config.seo.useUrlDispatcher && category.url_path) { + rootStore.dispatch('url/registerMapping', { + url: category.url_path, + routeData: { + params: { + 'slug': category.slug + }, + 'name': 'category' + } + }, { root: true }) + } let catSlugSetter = (category) => { if (category.children_data) { for (let subcat of category.children_data) { // TODO: fixme and move slug setting to vue-storefront-api if (subcat.name) { - subcat = Object.assign(subcat, { slug: (subcat.hasOwnProperty('url_key') && rootStore.state.config.products.useMagentoUrlKeys) ? subcat.url_key : (subcat.hasOwnProperty('name') ? slugify(subcat.name) + '-' + subcat.id : '') }) + subcat = Object.assign(subcat, { slug: subcat.hasOwnProperty('slug') ? subcat.slug : ((subcat.hasOwnProperty('url_key') && rootStore.state.config.products.useMagentoUrlKeys) ? subcat.url_key : (subcat.hasOwnProperty('name') ? slugify(subcat.name) + '-' + subcat.id : '')) }) catSlugSetter(subcat) } } diff --git a/core/modules/catalog/store/product/actions.ts b/core/modules/catalog/store/product/actions.ts index d7f0288b48..18a03a9b5b 100644 --- a/core/modules/catalog/store/product/actions.ts +++ b/core/modules/catalog/store/product/actions.ts @@ -25,6 +25,8 @@ 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 { isServer } from '@vue-storefront/core/helpers' + const PRODUCT_REENTER_TIMEOUT = 20000 const actions: ActionTree = { @@ -47,6 +49,7 @@ const actions: ActionTree = { 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 @@ -282,7 +285,7 @@ const actions: ActionTree = { * @param {Int} size page size * @return {Promise} */ - list (context, { query, start = 0, size = 50, entityType = 'product', sort = '', cacheByKey = 'sku', prefetchGroupProducts = !Vue.prototype.$isServer, updateState = false, meta = {}, excludeFields = null, includeFields = null, configuration = null, append = false, populateRequestCacheTags = true }) { + list (context, { 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 }) { let isCacheable = (includeFields === null && excludeFields === null) if (isCacheable) { Logger.debug('Entity cache is enabled for productList')() @@ -316,6 +319,18 @@ const actions: ActionTree = { let selectedVariant = configureProductAsync(context, { product: product, configuration: configuration, selectDefaultVariant: false }) Object.assign(product, selectedVariant) } + if (rootStore.state.config.seo.useUrlDispatcher && product.url_path) { + rootStore.dispatch('url/registerMapping', { + url: product.url_path, + routeData: { + params: { + 'parentSku': product.parentSku, + 'slug': product.slug + }, + 'name': product.type_id + '-product' + } + }, { root: true }) + } } } return calculateTaxes(resp.items, context).then((updatedProducts) => { @@ -341,7 +356,7 @@ const actions: ActionTree = { Logger.error('Cannot store cache for ' + cacheKey, err)() }) } - if ((prod.type_id === 'grouped' || prod.type_id === 'bundle') && prefetchGroupProducts && !Vue.prototype.$isServer) { + if ((prod.type_id === 'grouped' || prod.type_id === 'bundle') && prefetchGroupProducts && !isServer) { context.dispatch('setupAssociated', { product: prod }) } } @@ -623,7 +638,7 @@ const actions: ActionTree = { only_user_defined: true, includeFields: rootStore.state.config.entities.optimize ? rootStore.state.config.entities.attribute.includeFields : null }, { root: true })) - if (Vue.prototype.$isServer) { + if (isServer) { subloaders.push(context.dispatch('filterUnavailableVariants', { product: product })) } else { context.dispatch('filterUnavailableVariants', { product: product }) // exec async diff --git a/core/modules/url/hooks/afterRegistration.ts b/core/modules/url/hooks/afterRegistration.ts new file mode 100644 index 0000000000..c0c57193d1 --- /dev/null +++ b/core/modules/url/hooks/afterRegistration.ts @@ -0,0 +1,5 @@ +import { Logger } from '@vue-storefront/core/lib/logger' + +// This function will be fired both on server and client side context after registering other parts of the module +export function afterRegistration({ Vue, config, store, isServer }){ +} diff --git a/core/modules/url/hooks/beforeRegistration.ts b/core/modules/url/hooks/beforeRegistration.ts new file mode 100644 index 0000000000..f29f9c72f8 --- /dev/null +++ b/core/modules/url/hooks/beforeRegistration.ts @@ -0,0 +1,5 @@ +import { AsyncDataLoader } from '@vue-storefront/core/lib/async-data-loader' + +// This function will be fired both on server and client side context before registering other parts of the module +export function beforeRegistration({ Vue, config, store, isServer }) { +} diff --git a/core/modules/url/index.ts b/core/modules/url/index.ts new file mode 100644 index 0000000000..f9253a1466 --- /dev/null +++ b/core/modules/url/index.ts @@ -0,0 +1,79 @@ +import { module } from './store' +import { beforeRegistration } from './hooks/beforeRegistration' +import { afterRegistration } from './hooks/afterRegistration' +import { VueStorefrontModule, VueStorefrontModuleConfig } from '@vue-storefront/core/lib/module' +import { initCacheStorage } from '@vue-storefront/core/helpers/initCacheStorage' +import store from '@vue-storefront/store' +import userRoutes from 'theme/router' +import { HttpError } from '@vue-storefront/core/helpers/exceptions' +import { router } from '@vue-storefront/core/app' +import { isServer } from '@vue-storefront/core/helpers' +import { Logger } from '@vue-storefront/core/lib/logger'; +import UrlClientDispatcher from './pages/UrlClientDispatcher.vue' + +export const KEY = 'url' +export const cacheStorage = initCacheStorage(KEY) +let _matchedRouteData = null + +export const _handleDispatcherNotFound = (routeName: string):void => { + Logger.error('Route not found ' + routeName, 'dispatcher')() + if (isServer) { + throw new HttpError('UrlDispatcher query returned empty result', 404) + } else { + router.push('/page-not-found') + } +} +const UrlServerDispatcher = ():any => { + if (store.state.config.seo.useUrlDispatcher && _matchedRouteData) { + const userRoute = userRoutes.find(r => r.name === _matchedRouteData['name']) + if (userRoute) { + if (typeof userRoute.component === 'function') { + return userRoute.component() // supports only lazy loaded components; in case of eagerly loaded components it should be like: `return userRoute.component` + } else { + return userRoute.component + } + } else { + _handleDispatcherNotFound(_matchedRouteData['name']) + } + } else { + _handleDispatcherNotFound(null) + } +} +const moduleConfig: VueStorefrontModuleConfig = { + key: KEY, + store: { modules: [{ key: KEY, module }] }, + beforeRegistration, + afterRegistration +} + +export const UrlDispatchMapper = (to) => { + return store.dispatch('url/mapUrl', { url: to.fullPath, query: to.query }, { root: true }).then((routeData) => { + if (routeData) { + Object.keys(routeData.params).map(key => { + to.params[key] = routeData.params[key] + }) + return routeData + } else { + return null + } + }) +} + +export const UrlDispatcherGuard = (to, from, next) => { + if (store.state.config.seo.useUrlDispatcher) { + UrlDispatchMapper(to).then((routeData) => { + _matchedRouteData = routeData + next() + }).catch(e => { + Logger.error(e, 'dispatcher')() + next('/page-not-found') + }) + } else { + next() + } +} +export const DispatcherRoutes = [ + { name: 'urldispatcher', path: '*', component: isServer ? UrlServerDispatcher : UrlClientDispatcher, beforeEnter: isServer ? UrlDispatcherGuard : null } + //{ name: 'urldispatcher', path: '*', component: UrlServerDispatcher, beforeEnter: UrlDispatcherGuard } +] +export const Url = new VueStorefrontModule(moduleConfig) diff --git a/core/modules/url/pages/UrlClientDispatcher.vue b/core/modules/url/pages/UrlClientDispatcher.vue new file mode 100644 index 0000000000..3a18e4b717 --- /dev/null +++ b/core/modules/url/pages/UrlClientDispatcher.vue @@ -0,0 +1,76 @@ + + + diff --git a/core/modules/url/store/actions.ts b/core/modules/url/store/actions.ts new file mode 100644 index 0000000000..f9047fcfc0 --- /dev/null +++ b/core/modules/url/store/actions.ts @@ -0,0 +1,77 @@ +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 { parseURLQuery } from '@vue-storefront/core/helpers' +import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' + +// 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 + registerMapping ({ commit }, { url, routeData }) { + return new Promise ((resolve, reject) => { + commit(types.REGISTER_MAPPING, { url, routeData }) + cacheStorage.setItem(url, routeData).then(result => { + resolve(routeData) + }).catch(() => reject()) + }) + }, + mapUrl ({ state, dispatch }, { url, query }) { + const parsedQuery = typeof query === 'string' ? parseURLQuery(query) : query + if (url && url[0] === '/') url = url.slice(1) + const queryPos = url.indexOf('?') + if (queryPos > 0) url = url.slice(0, queryPos) + + return new Promise ((resolve, reject) => { + if (state.dispatcherMap.hasOwnProperty(url)) { + return resolve (state.dispatcherMap[url]) + } + cacheStorage.getItem(url).then(routeData => { + if (routeData !== null) { + return resolve(routeData) + } else { + dispatch('mappingFallback', { url, params: parsedQuery }).then(resolve).catch(reject) + } + }).catch(reject) + }) + }, + /** + * Router mapping fallback - get the proper URL from API + * This method could be overriden in custom module to provide custom URL mapping logic + */ + mappingFallback ({ commit, dispatch }, { url, params }) { + return new Promise ((resolve, reject) => { + const productQuery = new SearchQuery() + productQuery.applyFilter({key: 'url_path', value: {'eq': url}}) // Tees category + dispatch('product/list', { query: productQuery }, { root: true }).then((products) => { + if (products && products.items.length > 0) { + const product = products.items[0] + resolve({ + name: product.type_id + '-product', + params: { + slug: product.slug, + parentSku: product.sku, + childSku: params['childSku'] ? params['childSku'] : product.sku + } + }) + } else { + dispatch('category/single', { key: 'url_path', value: url }, { root: true }).then((category) => { + if (category !== null) { + resolve({ + name: 'category', + params: { + slug: category.slug + } + }) + } else { + resolve(null) + } + }).catch(e => reject(e)) + } + }).catch(e => reject(e)) + + + }) + } +} \ No newline at end of file diff --git a/core/modules/url/store/index.ts b/core/modules/url/store/index.ts new file mode 100644 index 0000000000..535e65aa2a --- /dev/null +++ b/core/modules/url/store/index.ts @@ -0,0 +1,13 @@ +import { Module } from 'vuex' +import { UrlState } from '../types/UrlState' +import { mutations } from './mutations' +import { actions } from './actions' + +export const module: Module = { + namespaced: true, + mutations, + actions, + state: { + dispatcherMap: {} + } +} \ No newline at end of file diff --git a/core/modules/url/store/mutation-types.ts b/core/modules/url/store/mutation-types.ts new file mode 100644 index 0000000000..9371d91eb4 --- /dev/null +++ b/core/modules/url/store/mutation-types.ts @@ -0,0 +1 @@ +export const REGISTER_MAPPING = 'REGISTER_MAPPING' diff --git a/core/modules/url/store/mutations.ts b/core/modules/url/store/mutations.ts new file mode 100644 index 0000000000..9dcae4e7b5 --- /dev/null +++ b/core/modules/url/store/mutations.ts @@ -0,0 +1,8 @@ +import { MutationTree } from 'vuex' +import * as types from './mutation-types' + +export const mutations: MutationTree = { + [types.REGISTER_MAPPING] (state, payload) { + state.dispatcherMap[payload.url] = payload.routeData + } +} \ No newline at end of file diff --git a/core/modules/url/store/state.ts b/core/modules/url/store/state.ts new file mode 100644 index 0000000000..ad5e784b78 --- /dev/null +++ b/core/modules/url/store/state.ts @@ -0,0 +1,5 @@ +import { UrlState } from '../types/UrlState' + +export const state: UrlState = { + dispatcherMap: {} +} \ No newline at end of file diff --git a/core/modules/url/types/UrlState.ts b/core/modules/url/types/UrlState.ts new file mode 100644 index 0000000000..bab5207de0 --- /dev/null +++ b/core/modules/url/types/UrlState.ts @@ -0,0 +1,5 @@ +// This object should represent structure of your modules Vuex state +// It's a good practice is to name this interface accordingly to the KET (for example mailchimpState) +export interface UrlState { + dispatcherMap: {} +} \ No newline at end of file diff --git a/core/pages/Category.js b/core/pages/Category.js index aba76a0315..9c4d0b997f 100644 --- a/core/pages/Category.js +++ b/core/pages/Category.js @@ -4,7 +4,7 @@ import toString from 'lodash-es/toString' import i18n from '@vue-storefront/i18n' import store from '@vue-storefront/store' import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' -import { baseFilterProductsQuery, buildFilterProductsQuery } from '@vue-storefront/core/helpers' +import { baseFilterProductsQuery, buildFilterProductsQuery, isServer } from '@vue-storefront/core/helpers' import { htmlDecode } from '@vue-storefront/core/filters/html-decode' import { currentStoreView, localizedRoute } from '@vue-storefront/core/lib/multistore' import Composite from '@vue-storefront/core/mixins/composite' @@ -59,7 +59,6 @@ export default { } }, watch: { - '$route': 'validateRoute', bottom (bottom) { if (bottom) { this.pullMoreProducts() @@ -76,8 +75,8 @@ export default { perPage: 50, sort: store.state.config.entities.productList.sort, filters: store.state.config.products.defaultFilters, - includeFields: store.state.config.entities.optimize && Vue.prototype.$isServer ? store.state.config.entities.productList.includeFields : null, - excludeFields: store.state.config.entities.optimize && Vue.prototype.$isServer ? store.state.config.entities.productList.excludeFields : null, + includeFields: store.state.config.entities.optimize && isServer ? store.state.config.entities.productList.includeFields : null, + excludeFields: store.state.config.entities.optimize && isServer ? store.state.config.entities.productList.excludeFields : null, append: false }) }, @@ -86,10 +85,10 @@ export default { Logger.info('Entering asyncData in Category Page (core)')() if (context) context.output.cacheTags.add(`category`) const defaultFilters = store.state.config.products.defaultFilters - store.dispatch('category/list', { level: store.state.config.entities.category.categoriesDynamicPrefetch && store.state.config.entities.category.categoriesDynamicPrefetchLevel ? store.state.config.entities.category.categoriesDynamicPrefetchLevel : null, includeFields: store.state.config.entities.optimize && Vue.prototype.$isServer ? store.state.config.entities.category.includeFields : null }).then((categories) => { + store.dispatch('category/list', { level: store.state.config.entities.category.categoriesDynamicPrefetch && store.state.config.entities.category.categoriesDynamicPrefetchLevel ? store.state.config.entities.category.categoriesDynamicPrefetchLevel : null, includeFields: store.state.config.entities.optimize && isServer ? store.state.config.entities.category.includeFields : null }).then((categories) => { store.dispatch('attribute/list', { // load filter attributes for this specific category filterValues: defaultFilters, // TODO: assign specific filters/ attribute codes dynamicaly to specific categories - includeFields: store.state.config.entities.optimize && Vue.prototype.$isServer ? store.state.config.entities.attribute.includeFields : null + includeFields: store.state.config.entities.optimize && isServer ? store.state.config.entities.attribute.includeFields : null }).catch(err => { Logger.error(err)() reject(err) @@ -141,7 +140,7 @@ export default { this.$bus.$on('user-after-loggedin', this.onUserPricesRefreshed) this.$bus.$on('user-after-logout', this.onUserPricesRefreshed) } - if (!Vue.prototype.$isServer && this.lazyLoadProductsOnscroll) { + if (!isServer && this.lazyLoadProductsOnscroll) { window.addEventListener('scroll', () => { this.bottom = this.bottomVisible() }, {passive: true}) @@ -155,6 +154,12 @@ export default { this.$bus.$off('user-after-logout', this.onUserPricesRefreshed) } }, + beforeRouteUpdate (to, from, next) { + if (!this.$store.state.config.seo.useUrlDispatcher) { + this.validateRoute(to) + next() + } + }, methods: { ...mapActions('category', ['mergeSearchOptions']), bottomVisible () { @@ -226,11 +231,11 @@ export default { this.notify() } }, - validateRoute () { + validateRoute (route = this.$route) { this.filters.chosen = {} // reset selected filters this.$bus.$emit('filter-reset') - this.$store.dispatch('category/single', { key: this.$store.state.config.products.useMagentoUrlKeys ? 'url_key' : 'slug', value: this.$route.params.slug }).then(category => { + this.$store.dispatch('category/single', { key: this.$store.state.config.products.useMagentoUrlKeys ? 'url_key' : 'slug', value: route.params.slug }).then(category => { if (!category) { this.$router.push(this.localizedRoute('/')) } else { @@ -251,7 +256,7 @@ export default { }) } this.$store.dispatch('category/products', this.getCurrentCategoryProductQuery) - EventBus.$emitFilter('category-after-load', { store: this.$store, route: this.$route }) + EventBus.$emitFilter('category-after-load', { store: this.$store, route: route }) } }).catch(err => { if (err.message.indexOf('query returned empty result') > 0) { diff --git a/core/pages/Product.js b/core/pages/Product.js index d55475b438..9f1795488a 100644 --- a/core/pages/Product.js +++ b/core/pages/Product.js @@ -62,8 +62,11 @@ export default { if (context) context.output.cacheTags.add(`product`) return store.dispatch('product/fetchAsync', { parentSku: route.params.parentSku, childSku: route && route.params && route.params.childSku ? route.params.childSku : null }) }, - watch: { - '$route.params.parentSku': 'validateRoute' + beforeRouteUpdate (to, from, next) { + if (!this.$store.state.config.seo.useUrlDispatcher) { + this.validateRoute(to) + next() + } }, beforeDestroy () { this.$bus.$off('product-after-removevariant') @@ -90,10 +93,10 @@ export default { this.$store.dispatch('recently-viewed/addItem', this.product) }, methods: { - validateRoute () { + validateRoute (route = this.$route) { if (!this.loading) { this.loading = true - this.$store.dispatch('product/fetchAsync', { parentSku: this.$route.params.parentSku, childSku: this.$route && this.$route.params && this.$route.params.childSku ? this.$route.params.childSku : null }).then(res => { + this.$store.dispatch('product/fetchAsync', { parentSku: route.params.parentSku, childSku: route && route.params && route.params.childSku ? route.params.childSku : null }).then(res => { this.loading = false this.defaultOfflineImage = this.product.image this.onStateCheck() diff --git a/core/scripts/server.js b/core/scripts/server.js index 60b5ef0d64..3834600e99 100755 --- a/core/scripts/server.js +++ b/core/scripts/server.js @@ -124,7 +124,7 @@ app.get('/invalidate', invalidateCache) app.get('*', (req, res, next) => { const s = Date.now() const errorHandler = err => { - if (err && err.code === 404) { + if (err && err.code === 404 || err.message.indexOf('Failed to resolve') >= 0/* Vue Router error - Vue Router encapsulates the HttpError returned by UrlDispatcher; haven't found a better way to handle it */) { res.redirect('/page-not-found') } else { res.redirect('/error') diff --git a/core/server-entry.ts b/core/server-entry.ts index d3dac07d54..098f0cfc05 100755 --- a/core/server-entry.ts +++ b/core/server-entry.ts @@ -60,11 +60,11 @@ function _ssrHydrateSubcomponents (components, store, router, resolve, reject, a export default async context => { const { app, router, store } = await createApp(context, context.vs && context.vs.config ? context.vs.config : buildTimeConfig) return new Promise((resolve, reject) => { + context.output.cacheTags = new Set() const meta = (app as any).$meta() router.push(context.url) context.meta = meta router.onReady(() => { - context.output.cacheTags = new Set() if (store.state.config.storeViews.multistore === true) { let storeCode = context.vs.storeCode // this is from http header or env variable if (router.currentRoute) { // this is from url diff --git a/core/store/lib/storage.ts b/core/store/lib/storage.ts index 451a620662..f9bd2cb93e 100644 --- a/core/store/lib/storage.ts +++ b/core/store/lib/storage.ts @@ -1,6 +1,7 @@ import Vue from 'vue' import * as localForage from 'localforage' import { Logger } from '@vue-storefront/core/lib/logger' +import { isServer } from '@vue-storefront/core/helpers' const CACHE_TIMEOUT = 800 const CACHE_TIMEOUT_ITERATE = 2000 @@ -51,7 +52,7 @@ class LocalForageCacheDriver { const dbName = collection._config.name this._storageQuota = storageQuota - if (this._storageQuota && !Vue.prototype.$isServer) { + if (this._storageQuota && !isServer) { const storageQuota = this._storageQuota const iterateFnc = this.iterate.bind(this) const removeItemFnc = this.removeItem.bind(this) @@ -84,7 +85,7 @@ class LocalForageCacheDriver { if (typeof this.cacheErrorsCount[collectionName] === 'undefined') { this.cacheErrorsCount[collectionName] = 0 } - if (Vue.prototype.$isServer) { + if (isServer) { this._localCache = {} } else { if (typeof Vue.prototype.$localCache === 'undefined') { @@ -144,7 +145,7 @@ class LocalForageCacheDriver { }) } - if (!Vue.prototype.$isServer) { + if (!isServer) { if (this.cacheErrorsCount[this._collectionName] >= DISABLE_PERSISTANCE_AFTER && this._useLocalCacheByDefault) { if (!this._persistenceErrorNotified) { Logger.error('Persistent cache disabled becasue of previous errors [get]', key)() @@ -294,7 +295,7 @@ class LocalForageCacheDriver { resolve(null) }) } - if (!Vue.prototype.$isServer) { + if (!isServer) { if (this.cacheErrorsCount[this._collectionName] >= DISABLE_PERSISTANCE_AFTER_SAVE && this._useLocalCacheByDefault) { if (!this._persistenceErrorNotified) { Logger.error('Persistent cache disabled becasue of previous errors [set]', key)() diff --git a/docs/guide/integrations/payment-gateway.md b/docs/guide/integrations/payment-gateway.md index d1c15c2a1b..c333a0842a 100644 --- a/docs/guide/integrations/payment-gateway.md +++ b/docs/guide/integrations/payment-gateway.md @@ -40,7 +40,7 @@ export function afterRegistration({ Vue, config, store, isServer }) { Vue.prototype.$bus.$emit('checkout-do-placeOrder', {}) } - if (!Vue.prototype.$isServer) { + if (!isServer) { // Update the methods let paymentMethodConfig = { 'title': 'Cash on delivery', diff --git a/src/modules/index.ts b/src/modules/index.ts index 2dee661c5e..8e27d6984a 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -10,6 +10,7 @@ import { Wishlist } from '@vue-storefront/core/modules/wishlist' import { Mailchimp } from '../modules/mailchimp' import { Notification } from '@vue-storefront/core/modules/notification' import { RecentlyViewed } from '@vue-storefront/core/modules/recently-viewed' +import { Url } from '@vue-storefront/core/modules/url' import { Homepage } from "./homepage" import { Claims } from './claims' import { PromotedOffers } from './promoted-offers' @@ -69,6 +70,7 @@ export const registerModules: VueStorefrontModule[] = [ PaymentBackendMethods, PaymentCashOnDelivery, RawOutputExample, - AmpRenderer/*, + AmpRenderer, + Url/*, Example*/ ] diff --git a/src/modules/module-template/hooks/beforeRegistration.ts b/src/modules/module-template/hooks/beforeRegistration.ts index 5ad8deadef..6395dcd9cf 100644 --- a/src/modules/module-template/hooks/beforeRegistration.ts +++ b/src/modules/module-template/hooks/beforeRegistration.ts @@ -2,7 +2,7 @@ import { AsyncDataLoader } from '@vue-storefront/core/lib/async-data-loader' // This function will be fired both on server and client side context before registering other parts of the module export function beforeRegistration({ Vue, config, store, isServer }) { - if (!Vue.prototype.$isServer) console.info('This will be called before extension registration and only on client side') + if (!isServer) console.info('This will be called before extension registration and only on client side') AsyncDataLoader.push({ // this is an example showing how to call data loader from another module execute: ({ route, store, context }) => { return new Promise ((resolve, reject) => { diff --git a/src/modules/payment-backend-methods/hooks/afterRegistration.ts b/src/modules/payment-backend-methods/hooks/afterRegistration.ts index 6bc72d77d8..270606899a 100644 --- a/src/modules/payment-backend-methods/hooks/afterRegistration.ts +++ b/src/modules/payment-backend-methods/hooks/afterRegistration.ts @@ -11,7 +11,7 @@ export function afterRegistration({ Vue, config, store, isServer }) { } } - if (!Vue.prototype.$isServer) { + if (!isServer) { // Update the methods Vue.prototype.$bus.$on('set-unique-payment-methods', methods => { store.commit('payment-backend-methods/' + types.SET_BACKEND_PAYMENT_METHODS, methods) diff --git a/src/modules/payment-cash-on-delivery/hooks/afterRegistration.ts b/src/modules/payment-cash-on-delivery/hooks/afterRegistration.ts index e77c9611b6..fbb90410f4 100644 --- a/src/modules/payment-cash-on-delivery/hooks/afterRegistration.ts +++ b/src/modules/payment-cash-on-delivery/hooks/afterRegistration.ts @@ -7,7 +7,7 @@ export function afterRegistration({ Vue, config, store, isServer }) { Vue.prototype.$bus.$emit('checkout-do-placeOrder', {}) } - if (!Vue.prototype.$isServer) { + if (!isServer) { // Update the methods let paymentMethodConfig = { 'title': 'Cash on delivery', diff --git a/src/themes/default-amp/components/core/ProductTile.vue b/src/themes/default-amp/components/core/ProductTile.vue index f81668b623..72b37c9e0b 100755 --- a/src/themes/default-amp/components/core/ProductTile.vue +++ b/src/themes/default-amp/components/core/ProductTile.vue @@ -5,14 +5,7 @@ >
- +
diff --git a/src/themes/default/components/core/ProductTile.vue b/src/themes/default/components/core/ProductTile.vue index a7914ff49e..534ee708cb 100644 --- a/src/themes/default/components/core/ProductTile.vue +++ b/src/themes/default/components/core/ProductTile.vue @@ -5,14 +5,7 @@ >