diff --git a/CHANGELOG.md b/CHANGELOG.md index 99fe057d6f..ca725fa00c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ 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.10.6] - 21.02.2020 + +## Added +- Add lazy create cart token - @gibkigonzo (#3994) +- Add server context to async data loader - @gibkigonzo (pr#4113) + +### Fixed +- Fix low-quality images styles - @przspa (#3906) +- Fix page-not-found redirect in dispatcher - @gibkigonzo (#3956) +- Fix hiding overlay for newsletter modal - @gibkigonzo (#3970) +- Fix problem with storeView as dependency in filters - @gibkigonzo (#3968) +- Fix v-model not working in BaseRadioButton - @lukeromanowicz (#4035) +- add disconnect and sync options for clear/cart - @gibkigonzo (#4062) +- Fix current token invalidation with refresh token - @gibkigonzo (#3928, #3620, #3626) +- Disable overriding `route`, `config`, `storeView`, `version` state in __INITIAL_STATE__ - @gibkigonzo (pr#4095) +- Disable zoom image on hover in mobile - @gibkigonzo (pr#4115) + ## [1.10.5] - 28.11.2019 ### Fixed diff --git a/config/default.json b/config/default.json index 41ae29c797..18663c591b 100644 --- a/config/default.json +++ b/config/default.json @@ -239,7 +239,6 @@ "width": 150, "height": 150 }, - "bypassCartLoaderForAuthorizedUsers": true, "serverMergeByDefault": true, "serverSyncCanRemoveLocalItems": false, "serverSyncCanModifyLocalItems": false, diff --git a/core/client-entry.ts b/core/client-entry.ts index e02f4b207c..71e8bd3793 100755 --- a/core/client-entry.ts +++ b/core/client-entry.ts @@ -13,6 +13,7 @@ import { AsyncDataLoader } from './lib/async-data-loader' import { Logger } from '@vue-storefront/core/lib/logger' import globalConfig from 'config' import { RouterManager } from './lib/router-manager'; +import omit from 'lodash-es/omit' declare var window: any const invokeClientEntry = async () => { @@ -22,7 +23,8 @@ const invokeClientEntry = async () => { const { app, router, store } = await createApp(null, dynamicRuntimeConfig, storeCode) if (window.__INITIAL_STATE__) { - store.replaceState(Object.assign({}, store.state, window.__INITIAL_STATE__, { config: globalConfig })) + const initialState = omit(window.__INITIAL_STATE__, ['storeView', 'config', 'version', 'route']) + store.replaceState(Object.assign({}, store.state, initialState, { config: globalConfig })) } await store.dispatch('url/registerDynamicRoutes') diff --git a/core/filters/date.js b/core/filters/date.js index 17ef6af253..16adde8b8c 100644 --- a/core/filters/date.js +++ b/core/filters/date.js @@ -12,9 +12,10 @@ once('__VUE_EXTEND_DAYJS_LOCALIZED_FORMAT__', () => { * @param {String} date * @param {String} format */ -export function date (date, format) { - const displayFormat = format || currentStoreView().i18n.dateFormat - let storeLocale = currentStoreView().i18n.defaultLocale.toLocaleLowerCase() +export function date (date, format, storeView) { + const _storeView = storeView || currentStoreView() + const displayFormat = format || _storeView.i18n.dateFormat + let storeLocale = _storeView.i18n.defaultLocale.toLocaleLowerCase() const separatorIndex = storeLocale.indexOf('-') const languageCode = separatorIndex ? storeLocale.substr(0, separatorIndex) : storeLocale diff --git a/core/filters/price.js b/core/filters/price.js index a9e44c7cba..377a220f2a 100644 --- a/core/filters/price.js +++ b/core/filters/price.js @@ -4,24 +4,24 @@ import { currentStoreView } from '@vue-storefront/core/lib/multistore' * Converts number to price string * @param {Number} value */ -export function price (value) { +export function price (value, storeView) { if (isNaN(value)) { return value } let formattedVal = Math.abs(parseFloat(value)).toFixed(2) - const storeView = currentStoreView() - if (!storeView.i18n) { + const _storeView = storeView || currentStoreView(); + if (!_storeView.i18n) { return value; } const prependCurrency = (price) => { - return storeView.i18n.currencySign + price + return _storeView.i18n.currencySign + price } const appendCurrency = (price) => { - return price + storeView.i18n.currencySign + return price + _storeView.i18n.currencySign } - if (storeView.i18n.currencySignPlacement === 'append') { + if (_storeView.i18n.currencySignPlacement === 'append') { formattedVal = appendCurrency(formattedVal) } else { formattedVal = prependCurrency(formattedVal) diff --git a/core/i18n/package.json b/core/i18n/package.json index f7f30bf8dd..7a17f39771 100644 --- a/core/i18n/package.json +++ b/core/i18n/package.json @@ -1,6 +1,6 @@ { "name": "@vue-storefront/i18n", - "version": "1.10.5", + "version": "1.10.6", "description": "Vue Storefront i18n", "license": "MIT", "main": "index.ts", diff --git a/core/lib/multistore.ts b/core/lib/multistore.ts index dbf90e9550..6d53ac54f6 100644 --- a/core/lib/multistore.ts +++ b/core/lib/multistore.ts @@ -8,20 +8,32 @@ import VueRouter, { RouteConfig, RawLocation } from 'vue-router' import config from 'config' import { LocalizedRoute, StoreView } from './types' import storeCodeFromRoute from './storeCodeFromRoute' +import cloneDeep from 'lodash-es/cloneDeep' +import get from 'lodash-es/get' +import { isServer } from '@vue-storefront/core/helpers' + +/** + * Returns base storeView object that can be created without storeCode + */ +function buildBaseStoreView (): StoreView { + return cloneDeep({ + tax: config.tax, + i18n: config.i18n, + elasticsearch: config.elasticsearch, + storeCode: null, + storeId: config.defaultStoreCode && config.defaultStoreCode !== '' ? config.storeViews[config.defaultStoreCode].storeId : 1, + seo: config.seo + }) +} export function currentStoreView (): StoreView { - // TODO: Change to getter all along our code - return rootStore.state.storeView + const serverStoreView = get(global, 'process.storeView', undefined) + const clientStoreView = get(rootStore, 'state.storeView', undefined) + return (isServer ? serverStoreView : clientStoreView) || buildBaseStoreView() } export async function prepareStoreView (storeCode: string): Promise { - let storeView = { // current, default store - tax: Object.assign({}, config.tax), - i18n: Object.assign({}, config.i18n), - elasticsearch: Object.assign({}, config.elasticsearch), - storeCode: '', - storeId: config.defaultStoreCode && config.defaultStoreCode !== '' ? config.storeViews[config.defaultStoreCode].storeId : 1 - } + let storeView: StoreView = buildBaseStoreView() // current, default store const storeViewHasChanged = !rootStore.state.storeView || rootStore.state.storeView.storeCode !== storeCode if (storeCode) { // current store code const currentStoreView = config.storeViews[storeCode] @@ -38,6 +50,11 @@ export async function prepareStoreView (storeCode: string): Promise { } if (storeViewHasChanged) { rootStore.state.storeView = storeView + + if (global && isServer) { + (global.process as any).storeView = storeView + } + await loadLanguageAsync(storeView.i18n.defaultLocale) } if (storeViewHasChanged || Vue.prototype.$db.currentStoreCode !== storeCode) { diff --git a/core/lib/sync/helpers/index.ts b/core/lib/sync/helpers/index.ts new file mode 100644 index 0000000000..45bf127f59 --- /dev/null +++ b/core/lib/sync/helpers/index.ts @@ -0,0 +1,31 @@ +export const hasResponseError = (jsonResponse): boolean => { + if (typeof jsonResponse.result === 'string') { + return true + } + + const hasMessage = jsonResponse.result.result || jsonResponse.result.message + + return Boolean(hasMessage) && jsonResponse.result.code !== 'ENOTFOUND' +} + +export const getResponseMessage = (jsonResponse): string => { + if (typeof jsonResponse.result === 'string') { + return jsonResponse.result + } + + if (typeof jsonResponse.result.result === 'string') { + return jsonResponse.result.result + } + + return jsonResponse.result.message +} + +export const getResponseCode = (jsonResponse): number => { + let responseCode = null + if (jsonResponse.result && jsonResponse.result.code) { + responseCode = parseInt(jsonResponse.result.code) + } else { + responseCode = parseInt(jsonResponse.code) + } + return responseCode +} diff --git a/core/lib/sync/task.ts b/core/lib/sync/task.ts index 1a3ace55c4..e3dc05f7b4 100644 --- a/core/lib/sync/task.ts +++ b/core/lib/sync/task.ts @@ -2,7 +2,6 @@ import Vue from 'vue' import i18n from '@vue-storefront/i18n' import isNaN from 'lodash-es/isNaN' import isUndefined from 'lodash-es/isUndefined' -import toString from 'lodash-es/toString' import fetch from 'isomorphic-fetch' import * as localForage from 'localforage' import rootStore from '@vue-storefront/core/store' @@ -16,6 +15,7 @@ import { processURLAddress } from '@vue-storefront/core/helpers' import { serial } from '@vue-storefront/core/helpers' import config from 'config' import { onlineHelper } from '@vue-storefront/core/helpers' +import { hasResponseError, getResponseMessage, getResponseCode } from '@vue-storefront/core/lib/sync/helpers' const AUTO_REFRESH_MAX_ATTEMPTS = 20 @@ -33,7 +33,7 @@ function _sleep (time) { } function _internalExecute (resolve, reject, task: Task, currentToken, currentCartId) { - if (currentToken !== null && rootStore.state.userTokenInvalidateLock > 0) { // invalidate lock set + if (currentToken && rootStore.state.userTokenInvalidateLock > 0) { // invalidate lock set Logger.log('Waiting for rootStore.state.userTokenInvalidateLock to release for ' + task.url, 'sync')() _sleep(1000).then(() => { Logger.log('Another try for rootStore.state.userTokenInvalidateLock for ' + task.url, 'sync')() @@ -42,7 +42,7 @@ function _internalExecute (resolve, reject, task: Task, currentToken, currentCar return // return but not resolve } else if (rootStore.state.userTokenInvalidateLock < 0) { Logger.error('Aborting the network task' + task.url + rootStore.state.userTokenInvalidateLock, 'sync')() - resolve({ code: 401, message: i18n.t('Error refreshing user token. User is not authorized to access the resource') })() + resolve({ code: 401, result: i18n.t('Error refreshing user token. User is not authorized to access the resource') })() return } else { if (rootStore.state.userTokenInvalidated) { @@ -73,9 +73,9 @@ function _internalExecute (resolve, reject, task: Task, currentToken, currentCar } }).then((jsonResponse) => { if (jsonResponse) { - const responseCode = parseInt(jsonResponse.code) + const responseCode = getResponseCode(jsonResponse) if (responseCode !== 200) { - if (responseCode === 401 /** unauthorized */ && currentToken !== null) { // the token is no longer valid, try to invalidate it + if (responseCode === 401 /** unauthorized */ && currentToken) { // the token is no longer valid, try to invalidate it Logger.error('Invalid token - need to be revalidated' + currentToken + task.url + rootStore.state.userTokenInvalidateLock, 'sync')() if (isNaN(rootStore.state.userTokenInvalidateAttemptsCount) || isUndefined(rootStore.state.userTokenInvalidateAttemptsCount)) rootStore.state.userTokenInvalidateAttemptsCount = 0 if (isNaN(rootStore.state.userTokenInvalidateLock) || isUndefined(rootStore.state.userTokenInvalidateLock)) rootStore.state.userTokenInvalidateLock = 0 @@ -128,12 +128,10 @@ function _internalExecute (resolve, reject, task: Task, currentToken, currentCar } } - if (!task.silent && jsonResponse.result && (typeof jsonResponse.result === 'string' || (((jsonResponse.result.result || jsonResponse.result.message) && jsonResponse.result.code !== 'ENOTFOUND') && !silentMode))) { - const message = typeof jsonResponse.result === 'string' ? jsonResponse.result : typeof jsonResponse.result.result === 'string' ? jsonResponse.result.result : jsonResponse.result.message - + if (!task.silent && jsonResponse.result && hasResponseError(jsonResponse) && !silentMode) { rootStore.dispatch('notification/spawnNotification', { type: 'error', - message: i18n.t(message), + message: i18n.t(getResponseMessage(jsonResponse)), action1: { label: i18n.t('OK') } }) } diff --git a/core/mixins/multistore.js b/core/mixins/multistore.js index 4f8135e186..8964566055 100644 --- a/core/mixins/multistore.js +++ b/core/mixins/multistore.js @@ -10,13 +10,7 @@ export const multistore = { * @param {Int} height */ localizedRoute (routeObj) { - let storeView - - if (isServer) { - storeView = this.$ssrContext.helpers.currentStoreView() - } else { - storeView = currentStoreView() - } + const storeView = currentStoreView() return localizedRouteHelper(routeObj, storeView.storeCode) }, @@ -27,13 +21,7 @@ export const multistore = { * @param {Int} height */ localizedDispatcherRoute (routeObj) { - let storeView - - if (isServer) { - storeView = this.$ssrContext.helpers.currentStoreView() - } else { - storeView = currentStoreView() - } + const storeView = currentStoreView() return localizedDispatcherRouteHelper(routeObj, storeView.storeCode) } diff --git a/core/modules/cart/store/actions.ts b/core/modules/cart/store/actions.ts index b0b63c15c7..1faf8d498a 100644 --- a/core/modules/cart/store/actions.ts +++ b/core/modules/cart/store/actions.ts @@ -135,13 +135,20 @@ const actions: ActionTree = { async disconnect ({ commit }) { commit(types.CART_LOAD_CART_SERVER_TOKEN, null) }, - /** Clear the cart content + re-connect to newly created guest cart */ - async clear ({ commit, dispatch, getters }, options = { recreateAndSyncCart: true }) { + /** + * It will always clear cart items on frontend. + * Options: + * sync - if you want to sync it with backend. + * disconnect - if you want to clear cart token. + */ + async clear ({ commit, dispatch }, { disconnect = true, sync = true } = {}) { await commit(types.CART_LOAD_CART, []) - if (options.recreateAndSyncCart && getters.isCartSyncEnabled) { - await commit(types.CART_LOAD_CART_SERVER_TOKEN, null) + if (sync) { + await dispatch('sync', { forceClientState: true }) + } + if (disconnect) { await commit(types.CART_SET_ITEMS_HASH, null) - await dispatch('connect', { guestCart: !config.orders.directBackendSync }) // guest cart when not using directBackendSync because when the order hasn't been passed to Magento yet it will repopulate your cart + await dispatch('disconnect') } }, /** Refresh the payment methods with the backend */ @@ -184,7 +191,7 @@ const actions: ActionTree = { } }, /** Sync the shopping cart with server along with totals (when needed) and shipping / payment methods */ - async sync ({ getters, rootGetters, commit, dispatch }, { forceClientState = false, dryRun = false }) { // pull current cart FROM the server + async sync ({ getters, rootGetters, commit, dispatch }, { forceClientState = false, dryRun = false, mergeQty = false }) { // pull current cart FROM the server const isUserInCheckout = rootGetters['checkout/isUserInCheckout'] let diffLog = _getDifflogPrototype() if (isUserInCheckout) forceClientState = true // never surprise the user in checkout - # @@ -201,7 +208,7 @@ const actions: ActionTree = { silent: true }).then(async task => { if (task.resultCode === 200) { - diffLog = await dispatch('merge', { serverItems: task.result, clientItems: getters.getCartItems, dryRun: dryRun, forceClientState: forceClientState }) + diffLog = await dispatch('merge', { serverItems: task.result, clientItems: getters.getCartItems, dryRun: dryRun, forceClientState: forceClientState, mergeQty }) } else { Logger.error(task.result, 'cart') // override with guest cart() if (_connectBypassCount < MAX_BYPASS_COUNT) { @@ -252,10 +259,8 @@ const actions: ActionTree = { Logger.info('Cart token received from cache.', 'cache', token)() Logger.info('Syncing cart with the server.', 'cart')() dispatch('sync', { forceClientState, dryRun: !config.cart.serverMergeByDefault }) - } else { - Logger.info('Creating server cart token', 'cart')() - await dispatch('connect', { guestCart: false }) } + await dispatch('create') } }, /** Get one single item from the client's cart */ @@ -350,6 +355,7 @@ const actions: ActionTree = { } productIndex++ } + await dispatch('create') if (getters.isCartSyncEnabled && getters.isCartConnected && !forceServerSilence) { return dispatch('sync', { forceClientState: true }) } else { @@ -498,16 +504,20 @@ const actions: ActionTree = { } return false }, + /** + * Create cart token when there are products in cart and we don't have token already + */ + async create ({ dispatch, getters }) { + const storedItems = getters['getCartItems'] || [] + const cartToken = getters['getCartToken'] + if (storedItems.length && !cartToken) { + Logger.info('Creating server cart token', 'cart')() + await dispatch('connect', { guestCart: false }) + } + }, /** authorize the cart after user got logged in using the current cart token */ authorize ({ dispatch }) { - Vue.prototype.$db.usersCollection.getItem('last-cart-bypass-ts', (err, lastCartBypassTs) => { - if (err) { - Logger.error(err, 'cart')() - } - if (!config.cart.bypassCartLoaderForAuthorizedUsers || (Date.now() - lastCartBypassTs) >= (1000 * 60 * 24)) { // don't refresh the shopping cart id up to 24h after last order - dispatch('connect', { guestCart: false }) - } - }) + dispatch('connect', { guestCart: false }) }, /** connect cart to the server and set the cart token */ async connect ({ getters, dispatch, commit }, { guestCart = false, forceClientState = false }) { @@ -517,7 +527,7 @@ const actions: ActionTree = { if (task.resultCode === 200) { Logger.info('Server cart token created.', 'cart', cartToken)() commit(types.CART_LOAD_CART_SERVER_TOKEN, cartToken) - return dispatch('sync', { forceClientState, dryRun: !config.cart.serverMergeByDefault }) + return dispatch('sync', { forceClientState, dryRun: !config.cart.serverMergeByDefault, mergeQty: true }) } else { let resultString = task.result ? toString(task.result) : null if (resultString && (resultString.indexOf(i18n.t('not authorized')) < 0 && resultString.indexOf('not authorized')) < 0) { // not respond to unathorized errors here @@ -536,7 +546,7 @@ const actions: ActionTree = { } }, /** merge shopping cart with the server results; if dryRun = true only the diff phase is being executed */ - async merge ({ getters, dispatch, commit, rootGetters }, { serverItems, clientItems, dryRun = false, forceClientState = false }) { + async merge ({ getters, dispatch, commit, rootGetters }, { serverItems, clientItems, dryRun = false, forceClientState = false, mergeQty = false }) { const diffLog = _getDifflogPrototype() let totalsShouldBeRefreshed = getters.isTotalsSyncRequired // when empty it means no sync has yet been executed let serverCartUpdateRequired = false @@ -642,7 +652,7 @@ const actions: ActionTree = { }) } } - } else if (serverItem.qty !== clientItem.qty) { + } else if (serverItem.qty !== clientItem.qty || mergeQty) { Logger.log('Wrong qty for ' + clientItem.sku, clientItem.qty, serverItem.qty)() diffLog.items.push({ 'party': 'server', 'sku': clientItem.sku, 'status': 'wrong-qty', 'client-qty': clientItem.qty, 'server-qty': serverItem.qty }) if (!dryRun) { @@ -651,7 +661,7 @@ const actions: ActionTree = { cartServerToken: getters.getCartToken, cartItem: { sku: clientItem.parentSku && config.cart.setConfigurableProductOptions ? clientItem.parentSku : clientItem.sku, - qty: clientItem.qty, + qty: mergeQty ? (clientItem.qty + serverItem.qty) : clientItem.qty, item_id: serverItem.item_id, quoteId: serverItem.quote_id, product_option: clientItem.product_option diff --git a/core/modules/cart/test/unit/store/actions.spec.ts b/core/modules/cart/test/unit/store/actions.spec.ts index 572672cf0f..d188b4c06a 100644 --- a/core/modules/cart/test/unit/store/actions.spec.ts +++ b/core/modules/cart/test/unit/store/actions.spec.ts @@ -26,7 +26,8 @@ jest.mock('@vue-storefront/core/lib/logger', () => ({ log: jest.fn(() => () => {}), debug: jest.fn(() => () => {}), warn: jest.fn(() => () => {}), - error: jest.fn(() => () => {}) + error: jest.fn(() => () => {}), + info: jest.fn(() => () => {}) } })); jest.mock('@vue-storefront/core/lib/sync', () => ({ TaskQueue: { @@ -73,6 +74,7 @@ describe('Cart actions', () => { it('clear deletes all cart products and token', async () => { const contextMock = { commit: jest.fn(), + dispatch: jest.fn(), getters: { isCartSyncEnabled: false } }; const wrapper = (actions: any) => actions.clear(contextMock); @@ -81,43 +83,87 @@ describe('Cart actions', () => { await wrapper(cartActions); - expect(contextMock.commit).toBeCalledWith(types.CART_LOAD_CART, []); + expect(contextMock.commit).toHaveBeenNthCalledWith(1, types.CART_LOAD_CART, []); + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'sync', { forceClientState: true }); + expect(contextMock.commit).toHaveBeenNthCalledWith(2, types.CART_SET_ITEMS_HASH, null); + expect(contextMock.dispatch).toHaveBeenNthCalledWith(2, 'disconnect'); }); - it('clear dispatches creating a new cart on server with direct backend sync when its configured', async () => { + it('clear deletes all cart products but keep token', async () => { const contextMock = { commit: jest.fn(), dispatch: jest.fn(), - getters: { isCartSyncEnabled: true, isTotalsSyncRequired: true, isSyncRequired: true, isCartConnected: true } + getters: { isCartSyncEnabled: false } }; + const wrapper = (actions: any) => actions.clear(contextMock, { disconnect: false }); - config.cart = { synchronize: true }; - config.orders = { directBackendSync: true }; - - const wrapper = (actions: any) => actions.clear(contextMock); + config.cart = { synchronize: false }; await wrapper(cartActions); - expect(contextMock.dispatch).toBeCalledWith('connect', {guestCart: false}); + expect(contextMock.commit).toHaveBeenNthCalledWith(1, types.CART_LOAD_CART, []); + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'sync', { forceClientState: true }); }); - it('clear dispatches creating a new cart on server with queuing when direct backend sync is not configured', async () => { + it('clear deletes all cart products and token, but not sync with backend', async () => { const contextMock = { commit: jest.fn(), dispatch: jest.fn(), - getters: { isCartSyncEnabled: true, isTotalsSyncRequired: true, isSyncRequired: true, isCartConnected: true } + getters: { isCartSyncEnabled: false } }; + const wrapper = (actions: any) => actions.clear(contextMock, { sync: false }); - config.cart = { synchronize: true }; - config.orders = { directBackendSync: false }; - - const wrapper = (actions: any) => actions.clear(contextMock); + config.cart = { synchronize: false }; await wrapper(cartActions); - expect(contextMock.dispatch).toBeCalledWith('connect', {guestCart: true}); + expect(contextMock.commit).toHaveBeenNthCalledWith(1, types.CART_LOAD_CART, []); + expect(contextMock.commit).toHaveBeenNthCalledWith(2, types.CART_SET_ITEMS_HASH, null); + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'disconnect'); }); + describe('create', () => { + it('Create cart token when there are products in cart and we don\'t have token already', async () => { + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: { getCartItems: [{id: 1}], getCartToken: '' } + }; + + const wrapper = (actions: any) => actions.create(contextMock); + + await wrapper(cartActions); + + expect(contextMock.dispatch).toBeCalledWith('connect', {guestCart: false}); + }) + it('doesn\'t create cart token when there are NO products in cart', async () => { + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: { getCartItems: [], getCartToken: '' } + }; + + const wrapper = (actions: any) => actions.create(contextMock); + + await wrapper(cartActions); + + expect(contextMock.dispatch).toHaveBeenCalledTimes(0); + }) + it('doesn\'t create cart token when there are products in cart but we have token already', async () => { + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: { getCartItems: [{id: 1}], getCartToken: 'xyz' } + }; + + const wrapper = (actions: any) => actions.create(contextMock); + + await wrapper(cartActions); + + expect(contextMock.dispatch).toHaveBeenCalledTimes(0); + }) + }) + describe('sync', () => { it('doesn\'t update shipping methods if cart is empty', async () => { const contextMock = { diff --git a/core/modules/checkout/components/Payment.ts b/core/modules/checkout/components/Payment.ts index fe7832786f..b193f2eb9f 100644 --- a/core/modules/checkout/components/Payment.ts +++ b/core/modules/checkout/components/Payment.ts @@ -1,6 +1,7 @@ import { mapState, mapGetters } from 'vuex' import RootState from '@vue-storefront/core/types/RootState' import toString from 'lodash-es/toString' +import debounce from 'lodash-es/debounce' const Countries = require('@vue-storefront/i18n/resource/countries.json') export const Payment = { @@ -69,6 +70,11 @@ export const Payment = { handler () { this.useGenerateInvoice() } + }, + paymentMethods: { + handler: debounce(function () { + this.changePaymentMethod() + }, 500) } }, methods: { diff --git a/core/modules/checkout/store/checkout/actions.ts b/core/modules/checkout/store/checkout/actions.ts index 58d1a02e72..bf44bbec86 100644 --- a/core/modules/checkout/store/checkout/actions.ts +++ b/core/modules/checkout/store/checkout/actions.ts @@ -16,7 +16,8 @@ const actions: ActionTree = { const result = await dispatch('order/placeOrder', order, {root: true}) if (!result.resultCode || result.resultCode === 200) { Vue.prototype.$db.usersCollection.setItem('last-cart-bypass-ts', new Date().getTime()) - await dispatch('cart/clear', { recreateAndSyncCart: true }, {root: true}) + // clear cart without sync, because after order cart will be already cleared on backend + await dispatch('cart/clear', { sync: false }, {root: true}) if (state.personalDetails.createAccount) { commit(types.CHECKOUT_DROP_PASSWORD) } diff --git a/core/modules/order/store/actions.ts b/core/modules/order/store/actions.ts index cce27044a8..707e35d192 100644 --- a/core/modules/order/store/actions.ts +++ b/core/modules/order/store/actions.ts @@ -32,7 +32,7 @@ const actions: ActionTree = { } Vue.prototype.$bus.$emit('order-before-placed', { order: order }) - if (!config.orders.directBackendSync || !isOnline()) { + if (!isOnline()) { commit(types.ORDER_PLACE_ORDER, order) Vue.prototype.$bus.$emit('order-after-placed', { order: order }) return { diff --git a/core/modules/url/router/beforeEach.ts b/core/modules/url/router/beforeEach.ts index 72ad0d74d0..8e4b5b82db 100644 --- a/core/modules/url/router/beforeEach.ts +++ b/core/modules/url/router/beforeEach.ts @@ -28,8 +28,9 @@ export async function beforeEach (to: Route, from: Route, next) { const path = normalizeUrlPath(to.path) const hasRouteParams = to.hasOwnProperty('params') && Object.values(to.params).length > 0 const isPreviouslyDispatchedDynamicRoute = to.matched.length > 0 && to.name && to.name.startsWith('urldispatcher') - if (!to.matched.length || (isPreviouslyDispatchedDynamicRoute && !hasRouteParams)) { - UrlDispatchMapper(to).then((routeData) => { + if (!to.matched.length || to.matched[0].name.endsWith('page-not-found') || (isPreviouslyDispatchedDynamicRoute && !hasRouteParams)) { + try { + const routeData = await UrlDispatchMapper(to) if (routeData) { let dynamicRoutes: LocalizedRoute[] = processDynamicRoute(routeData, path, !isPreviouslyDispatchedDynamicRoute) if (dynamicRoutes && dynamicRoutes.length > 0) { @@ -39,28 +40,23 @@ export async function beforeEach (to: Route, from: Route, next) { }) } else { Logger.error('Route not found ' + routeData['name'], 'dispatcher')() - next(localizedRoute('/page-not-found', currentStoreView().storeCode)) + next() } } else { Logger.error('No mapping found for ' + path, 'dispatcher')() - next(localizedRoute('/page-not-found', currentStoreView().storeCode)) + next() } - }).catch(e => { + } catch (e) { Logger.error(e, 'dispatcher')() - if (!isServer) { - next(localizedRoute('/page-not-found', currentStoreView().storeCode)) - } else { - const storeCode = currentStoreView().storeCode - Vue.prototype.$ssrRequestContext.server.response.redirect((storeCode !== '' ? ('/' + storeCode) : '') + '/page-not-found') // TODO: Refactor this one after @filrak will give us a way to access ServerContext from Modules directly :-) - // ps. we can't use the next() call here as it's not doing the real redirect in SSR mode (just processing different component without changing the URL and that causes the CSR / SSR DOM mismatch while hydrating) - } - }).finally(() => { - routerHelper.popStateDetected = false + next() + } finally { RouterManager.unlockRoute() - }) + } } else { next() RouterManager.unlockRoute() routerHelper.popStateDetected = false } + + routerHelper.popStateDetected = false } diff --git a/core/modules/user/hooks/afterRegistration.ts b/core/modules/user/hooks/afterRegistration.ts index 7541c51902..1535ad321a 100644 --- a/core/modules/user/hooks/afterRegistration.ts +++ b/core/modules/user/hooks/afterRegistration.ts @@ -3,7 +3,7 @@ import * as types from './../store/mutation-types' export async function afterRegistration ({ Vue, config, store, isServer }) { if (!isServer) { - await store.dispatch('user/startSession') + store.dispatch('user/startSession') Vue.prototype.$bus.$on('user-before-logout', () => { store.dispatch('user/logout', { silent: false }) diff --git a/core/modules/user/store/actions.ts b/core/modules/user/store/actions.ts index 109dde4bd3..15852ea3ce 100644 --- a/core/modules/user/store/actions.ts +++ b/core/modules/user/store/actions.ts @@ -188,7 +188,7 @@ const actions: ActionTree = { context.commit(types.USER_INFO_LOADED, res) context.dispatch('setUserGroup', res) Vue.prototype.$bus.$emit('user-after-loggedin', res) - rootStore.dispatch('cart/authorize') + context.dispatch('cart/authorize', null, { root: true }) resolve(res) resolvedFromCache = true @@ -214,7 +214,7 @@ const actions: ActionTree = { } if (!resolvedFromCache && resp.resultCode === 200) { Vue.prototype.$bus.$emit('user-after-loggedin', resp.result) - rootStore.dispatch('cart/authorize') + context.dispatch('cart/authorize', null, { root: true }) resolve(resp) } else { resolve(null) @@ -297,7 +297,9 @@ const actions: ActionTree = { context.dispatch('cart/disconnect', {}, { root: true }) .then(() => { context.dispatch('clearCurrentUser') }) .then(() => { Vue.prototype.$bus.$emit('user-after-logout') }) - .then(() => { context.dispatch('cart/clear', { recreateAndSyncCart: true }, { root: true }) }) + // clear cart without sync, because after logout we don't want to clear cart on backend + // user should have items when he comes back + .then(() => { context.dispatch('cart/clear', { sync: false }, { root: true }) }) if (!silent) { rootStore.dispatch('notification/spawnNotification', { type: 'success', diff --git a/core/server-entry.ts b/core/server-entry.ts index 5a03915503..5cae194415 100755 --- a/core/server-entry.ts +++ b/core/server-entry.ts @@ -31,7 +31,7 @@ function _ssrHydrateSubcomponents (components, store, router, resolve, reject, a return Promise.resolve(null) } })).then(() => { - AsyncDataLoader.flush({ store, route: router.currentRoute, context: null } /* AsyncDataLoaderActionContext */).then((r) => { + AsyncDataLoader.flush({ store, route: router.currentRoute, context } /* AsyncDataLoaderActionContext */).then((r) => { if (buildTimeConfig.ssr.useInitialStateFilter) { context.state = omit(store.state, config.ssr.initialStateFilter) } else { diff --git a/docs/guide/basics/configuration.md b/docs/guide/basics/configuration.md index e157b2b6ee..eb14e639aa 100644 --- a/docs/guide/basics/configuration.md +++ b/docs/guide/basics/configuration.md @@ -256,13 +256,6 @@ Starting with Vue Storefront 1.7, we added a configuration option `config.entiti ## Cart -```json -"cart": { - "bypassCartLoaderForAuthorizedUsers": true, -``` - -The cart-loader bypass feature is there because we're posting orders to Magento asynchronously. It may happen that directly after placing an order, the Magento’s user still has the same quote ID, and after browsing through the VS store, old items will be restored to the shopping cart. Now you can disable this behavior by setting `bypassCartLoaderForAuthorizedUsers` option to `false` - ```json "cart": { "serverMergeByDefault": true, diff --git a/docs/guide/cookbook/setup.md b/docs/guide/cookbook/setup.md index 81201c4221..3e2b24b884 100644 --- a/docs/guide/cookbook/setup.md +++ b/docs/guide/cookbook/setup.md @@ -1097,7 +1097,6 @@ At [`vue-storefront/config/default.json`](https://github.com/DivanteLtd/vue-stor } }, "cart": { - "bypassCartLoaderForAuthorizedUsers": true, "serverMergeByDefault": true, "serverSyncCanRemoveLocalItems": false, "serverSyncCanModifyLocalItems": false, diff --git a/docs/guide/cookbook/theme.md b/docs/guide/cookbook/theme.md index 4283dee3fc..f5d65eab82 100644 --- a/docs/guide/cookbook/theme.md +++ b/docs/guide/cookbook/theme.md @@ -762,7 +762,7 @@ Start with replacing _template_ at `2` as follows : -