diff --git a/.eslintignore b/.eslintignore index 02e3bf04f..0779adf9c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,3 @@ core/build/*.js node_modules +packages/module/*.js \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 2d98134c4..3697d18fb 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,6 +1,7 @@ module.exports = { root: true, env: { browser: true, jest: true }, + globals: { fetchMock: true }, parser: 'vue-eslint-parser', parserOptions: { parser: '@typescript-eslint/parser', diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index cb154c616..2290161f2 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,17 +1,17 @@ -### Related issues +### Related Issues closes # -### Short description and why it's useful +### Short Description and Why It's Useful -### Screenshots of visual changes before/after (if there are any) +### Screenshots of Visual Changes before/after (if There Are Any) -### Which environment this relates to +### Which Environment This Relates To Check your case. In case of any doubts please read about [Release Cycle](https://docs.vuestorefront.io/guide/basics/release-cycle.html) - [ ] Test version (https://test.storefrontcloud.io) - this is a new feature or improvement for Vue Storefront. I've created branch from `develop` branch and want to merge it back to `develop` @@ -19,13 +19,12 @@ Check your case. In case of any doubts please read about [Release Cycle](https:/ - [ ] Stable version (https://demo.storefrontcloud.io) - this is an important fix for current stable version. I've created branch from `hotfix` or `master` branch and want to merge it back to `hotfix` ### Upgrade Notes and Changelog - - [x] No upgrade steps required (100% backward compatibility and no breaking changes) -- [ ] I've updated the [Upgrade notes](https://github.com/DivanteLtd/vue-storefront/blob/develop/docs/guide/upgrade-notes/README.md) and [Changelog](https://github.com/DivanteLtd/vue-storefront/blob/develop/CHANGELOG.md) on how to port existing VS sites with this new feature +- [ ] I've updated the [Upgrade notes](https://github.com/DivanteLtd/vue-storefront/blob/develop/docs/guide/upgrade-notes/README.md) and [Changelog](https://github.com/DivanteLtd/vue-storefront/blob/develop/CHANGELOG.md) on how to port existing Vue Storefront sites with this new feature **IMPORTANT NOTICE** - Remember to update `CHANGELOG.md` with description of your change -### Contribution and currently important rules acceptance +### Contribution and Currently Important Rules Acceptance - [ ] I read and followed [contribution rules](https://github.com/DivanteLtd/vue-storefront/blob/master/CONTRIBUTING.md) diff --git a/.gitignore b/.gitignore index 15c6ca7cb..2ecc09f38 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ core/resource/i18n/ru-RU.json #unit testing /test/unit/coverage +/static diff --git a/.huskyrc.js b/.huskyrc.js new file mode 100644 index 000000000..08aa197b0 --- /dev/null +++ b/.huskyrc.js @@ -0,0 +1,12 @@ +const tasks = arr => arr.join(' && ') + +module.exports = { + 'hooks': { + 'pre-commit': tasks([ + 'lint-staged' + ]), + 'pre-push': tasks([ + 'yarn test:unit' + ]) + } +} diff --git a/.lintstagedrc.js b/.lintstagedrc.js new file mode 100644 index 000000000..29c2ede19 --- /dev/null +++ b/.lintstagedrc.js @@ -0,0 +1,4 @@ +module.exports = { + "*.{js,vue,ts}": "eslint", + "**/i18n/*.csv": ["node ./core/scripts/utils/sort-translations.js", "git add"] +} diff --git a/.travis.yml b/.travis.yml index 9be62fd92..6364ccfcd 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,18 +18,12 @@ jobs: - yarn build node_js: "10" - - <<: *build - node_js: '8' - - &unit stage: Test script: yarn test:unit name: "NodeJS 10 unit tests" node_js: "10" - - <<: *unit - name: "NodeJS 8 unit tests" - node_js: "8" - &installer script: yarn installer:ci @@ -39,7 +33,4 @@ jobs: addons: chrome: stable - - <<: *installer - name: "NodeJS 8 installer test" - node_js: "8" diff --git a/CHANGELOG.md b/CHANGELOG.md index 99fe057d6..52be74608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,286 @@ # Changelog + 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.11.0] - 2019.12.20 + +### Added +- Add unit tests for `core/modules/url` - @dz3n (#3469) +- Add unit test for `core/modules/checkout` - @psmyrek (#3460) +- Add defense against incomplete config in ssr renderer - @oskar1233 (#3774) +- Add unit tests for `core/modules/order` - @dz3n (#3466) +- Add unit tests for `core/modules/user` - @dz3n (#3470) +- Add to cart from Wishlist and Product listing for simple products - @Dnd-Dboy, @dz3n (#2637) +- Add global Category and Breadcrumb filters, defined in local.json - @grimasod (#3691) +- Add constant which conditions the number of products loading per page - @AdKamil (#3630) +- Added price filtering key as config - @roywcm + +### Fixed +- Fixed missing parameter to query function from cms/store/block/actions - @georgiev-ivan (#3909) +- Always close zoom overlay after changing product - @psmyrek (#3818) +- Fixed problem with cutting image height in category page on 1024px+ screen res - @AdKamil (#3781) +- Fixed null value of search input - @AdKamil (#3778) +- Fixed product sorting - @AdKamil (#3785) +- Fixed displaying `sale` and `new` mark - @andrzejewsky (#3800) +- Fixed sorting on category page and product tile sizing - @andrzejewsky (#3817) +- Redirect from simple product using url_path - @benjick (#3804) +- Mount app in 'beforeResolve' if it's not dispatched in 'onReady' - @gibkigonzo (#3669) +- Fixed AMP pages - @andrzejewsky (#3799) +- Fixed Product page breadcrumbs problem when products are in multiple categories in different branches of the category tree - @grimasod (#3691) +- Change translation from jp-JP to ja-JP - @gibkigonzo (#3824) +- Fixed ecosystem config for pm2 - @andrzejewsky (#3842) +- Fixed `mappingFallback` for extending modules - @andrzejewsky (#3822) +- Fixed adding products search results to category-next product store - @grimasod (#3877) +- Use `defaultSortBy` for sorting category products by default @haelbichalex (#3873) +- Fixed some potential mutations of Config object in `catalog` and `catalog-next` - @grimasod (#3843) +- Set `null` as default value for custom option in product page - @gibkigonzo (#3885) +- Fixed Breadcrumb filters - apply to second category fetch - @grimasod (#3887) +- Fixed `config.storeViews.commonCache` being ignored - @grimasod (#3895) +- Fixed static pages, password notification, offline mode #3902 - @andrzejewsky (#3902) +- Fixed error page display with enabled multistore - @gibkigonzo (#3890) +- Fixed edit shipping address in my account - @gibkigonzo (#3921) +- Fetch cms_block content in serverPrefetch method - @gibkigonzo (#3910) +- Fixed saving invalidated user token - @andrzejewsky (#3923) +- Keep category products objects on ssr - @gibkigonzo (#3924) +- product breadcrumbs - check if current category is not highest one - @gibkigonzo (#3933) + +### Changed / Improved +- Changed pre commit hook to use NODE_ENV production to check for debugger statements - @resubaka (#3686) +- Improve the readability of 'getShippingDetails()' and 'updateDetails()' method of UserShippingDetails component - @adityasharma7 (#3770) +- Keep git after yarn install in dockerfile - @ddanier (#3826) +- Update the Storage Manager shipping details cache immediately when then Vuex checkout store is updated - @grimasod (#3894) + +## [1.11.0-rc.2] - 2019.10.31 + +### Added +- Add defense for incomplete config in preferchCachedAttributes helper +- Add unit test for \`core/modules/cms\` - @krskibin (#3738) + +### Fixed +- Fixed deprecated getter in cmsBlock store - @resubaka (#3683) +- Fixed problem around dynamic urls when default storeView is set with appendStoreCode false and url set to / . @resubaka (#3685) +- Fixed three problems you can run into when you have bundle products - @resubaka (#3692) +- Reset nested menu after logout - @gibkigonzo (#3680) +- Fixed handling checkbox custom option - @gibkigonzo (#2781) +- Fixed typos in docs - @afozbek (#3709) +- Fixed VSF build fails for some people due to lack of dependencies in the container - @krskibin (#3699) +- Fixed two graphql problems, one with cms_blocks and the other with default sort order - @resubaka (#3718) +- Allow falsy value for `parent_id` when searching category - @gibkigonzo (#3732) +- Remove including .map files in service worker cache - @gibkigonzo (#3734) +- Changed notification message object to factory fn - @gibkigozno (#3716) +- Load recently viewed module in my account page - @gibkigonzo (#3722) +- Added validation message for city field on checkout page - @dz3n (#3723) +- Make price calculation based on saved original prices - @gibkigonzo (#3740) +- Improving is_comparable to work with booleans and digits - @dz3n (#3697) +- Fixed displaying categories on search menu - @andrzejewsky (#3758) +- Fixed broken link for store locator - @andrzejewsky (#3754) +- Fixed instant checkout functionality - @andrzejewsky (#3765) +- Fixed links to the promoted banners - @andrzejewsky (#3753) +- Fixed missing parameter in the compare list - @andrzejewsky (#3757) +- Fixed product link on mobile - @andrzejewsky (#3772) +- Custom module `ConfigProvider` aren't called anymore - @cewald (#3797) + +### Added +- Added Estonian translations - @alphpkeemik +- Added support for ES7 - @andrzejewsky (#3690) +- Added unit tests for `core/modules/mailer` - @krskibin (#3710) +- Get payment methods with billing address data - @rain2o (#2878) +- Added custom page-size parameter for `category-next/loadCategoryProducts` action - @cewald (#3713, #3714) +- Remove unused dayjs locales - @gibkigonzo (#3498) +- check max quantity in microcart - @gibkigonzo (#3314) +- Add unit tests for `core/modules/newsletter` - @psmyrek (#3464) +- Add unit test for `core/modules/wishlist` - @psmyrek (#3471) + +### Changed / Improved +- Use `encodeURIComponent` to encode get parameters in `multimatch.js` - @adityasharma7 (#3736) + +## [1.11.0-rc.1] - 2019.10.03 + +### Added + +- Add unit testing to Husky on pre-push hook - @mattheo-geoffray (#3475) +- Add unit testing on breadcrumbs feature - @mattheo-geoffray (#3457) +- HTML Minifier has been added, to enable it please switch the `config.server.useHtmlMinifier` - @pkarw (#2182) +- Output compression module has been added; it's enabled by default on production builds; to disable it please switch the `src/modules/serrver.ts` configuration - @pkarw (#2182) +- Sort CSV i18n files alphabetically in pre-commit Git hook - @defudef (#2657) +- Cache invalidate requests forwarding support - @pkarw (#3367) +- Extend storeview config after another storeview in multistore mode - @lukeromanowicz (#3057, #3270) +- Default storeview settings are now overridden by specific storeview settings - @lukeromanowicz (#3057) +- Apache2 proxy header support for store based on host - @resubaka (#3143) +- Items count badges for Compare products and wishlist icons at header - @vishal-7037 (#3047) +- Added product image in order summary - @obsceniczny (#2544) +- Add icons on the product tiles that allow to add to the wish list and to the list to compare products from the list of products - @Michal-Dziedzinski (#2773) +- Get also none product image thumbnails via API - @cewald, @resubaka (#3207) +- Added a config option `optimizeShoppingCartOmitFields` - @EmilsM (#3222) +- Added information on the number of available products - @Michal-Dziedzinski (#2733) +- Added possibility to change color or size of the product that is already in the cart - @andrzejewsky (#2346) +- Experimental static files generator - @pkarw (#3246) +- Added price formatting based on locales in multistore - @andrzejewsky (#3060) +- Added support for tax calculation where the values from customer_tax_class_ids is used - @resubaka (#3245) +- Added loading product attributes (`entities.productListWithChildren.includeFields`) on category page - @andrzejewsky (#3220) +- Added config to set Cache-Control header for static assets based on mime type - @phoenix-bjoern (#3268) +- Improve `category-next/getCategoryFrom` and `category-next/getCurrentCategory` to be more flexible - @cewald (#3295) +- Added test:unit:watch with a workaround of a jest problem with template strings - @resubaka (#3351) +- Added test to multistore.ts so it is nearly fully unit tested - @resubaka (#3352) +- Added test:unit:watch with a workaround of a jest problem with template strings - @resubaka (#3351, #3354) +- Added test to helpers/index.ts so it is partly tested - @resubaka (#3376, 3377) +- Added hooks in cart module - @andrzejewsky (#3388) +- Added config for the defaultTitle compitable with multistore - @cnviradiya (#3282) +- Added husky package to manage lint check only for staged files in git @lorenaramonda (#3444) +- Change text from "is out of the stock" to "is out of stock" - @indiebytes (#3452) +- Added general purpose hooks - @andrzejewsky (#3389) +- Added loading of your own searchAdaptor - @resubaka (#3405K) +- Added lazy hydration for home page - @filrak (#3496, #3565) +- Added i18n support for modules - @dz3n (#3369) +- Added support for creating localized child routes - @gibkigonzo (#3489) +- Added tests for actions and mutations in 'core/modules/recently-viewed' - @gibkigonzo (#3467) +- Added tests for actions, mutations and components in 'core/modules/compare' - @gibkigonzo (#3467) +- Added support to load tracing libs at the start of the app - @resubaka (#3514, #3566) +- Added tests for actions and mutations in 'core/modules/notification' - @gibkigonzo (#3465) +- Added tests for actions, mutations and helpers in 'core/modules/review' - @gibkigonzo (#3468) +- Add new Google-Tag-Manager module using new module registration - @cewald (#3524, #3509) +- Exclude GTM product attributes setup into config json - @dlandmann, @cewald (#3509, #3524) +- Add configuration option to format currency sign placement and space in price - @cewald (#3574) +- Add ability to pass `pageSize` and `currentPage` to order history API call for pagination - @rain2o +- Added italian translations - @lorenaramonda (3076) +- Route Manager Queue for adding routes efficiently and with an optional priority - @grimasod (#3540) +- Added tests for cart module actions - @andrzejewsky (#3023) +- Fixed a problem with type changes in the state when extending a store - @resubaka (#3618) + +### Fixed + +- Attributes loader, breadcrumbs loader fixes - @pkarw (#3636) +- Fix for the product attribute labels displayedd on the PDP - @pkarw (#3530) +- Fix the mix of informal and polite personal pronouns for German translations - @nhp (#3533) +- Fix for comparison list being not preserved between page reloads - @vue-kacper (#3508) +- Fix 'fist' typos - @jakubmakielkowski (#3491) +- Fix for wrong breadcrumb urls in the multistore mode - @pkarw (#3359) +- Fix for displaying gallery images for unavaialble product variants - @pkarw (#3436) +- Fix for `null` in search query input - @pkarw (#3474) +- Unable to place order has been fixed; the `entities` module was wrongly imported - @pkarw (#3453) +- Fixed product link in wishlist and microcart - @michasik (#2987) +- Fixed naming strategy for product prices - `special_priceInclTax` -> `special_price_incl_tax`, `priceInclTax` -> `price_incl_tax`, `priceTax` -> `price_tax`; old names have been kept as @deprecated - @pkarw (#2918) +- The `final_price` field is now being used for setting the `special_price` or `price` of the product (depending on the value); `final_price` might been used along with `special_price` with Magento for the products with activated catalog pricing rules - @pkarw (#3099) +- Resolve problem with getting CMS block from cache - @qiqqq (#2499) +- Make image proxy url work with relative base url - @cewald (#3158) +- Fixed memory leak with enabled dynamicConfigReload - @dimasch (#3075) +- Fixed error for the orderhistory null for google-tag-manager extension - @cnviradiya (#3195) +- Fixed swatches not rendering properly at product detail page issue - @vishal-7037 (#3206) +- Fixed label of configurable options in cart after product just added - @cheeerd (#3164) +- Fixed eslint warning in Product Page, removed v-if from v-for node - @przspa (#3181) +- Fixed aspect ratio in ProductImage component - @przspa (#3187) +- Fixed AMP Product page - @przspa (#3227) +- Fixed when store has updated, but plugin didn't called - @serzilo (#3238) +- Fixed first call of prepareStoreView when SSR - @resubaka (#3244) +- Add ./packages as volume to docker-compose.yml - @cewald (#3251) +- Fixed mail sending and add error logger - @Michal-Dziedzinski (#3265) +- Fixed page not found http status code - @phoenix-bjoern (#3243) +- Fixed missing coupon code after user logged in - @andrzejewsky (#3153) +- Fixed bug around appendStoreCode in formatCategoryLink. - @resubaka (#3306) +- Fixed static category links in cms contents on homepage and MinimalFooter - @MariaKern (#3292) +- Fixed tax calulaction where products was send as parameter but products.items where the right paramater - @resubaka (#3308) +- Fixed module extendStore for array property inside store - @przspa (#3311) +- Fixed ordering of the categories and subcategories in sidebar - @andrzejewsky (#2665) +- Some SSR problems with urlDispatcher during multireloading page - @patzick (#3323) +- Fixed two bugs in `category-next/getCategoryFrom` (#3286) and `category-next/getCurrentCategory` (#3332) - @cewald (#3295) +- Fixed login popup close icon position - @przspa (#3393) +- Fixed styles for original price on Wishlist sidebar - @przspa (#3392) +- Redirect loop on dispatching dynamic routes in CSR running multistore mode - @cewald, @lukeromanowicz, @resubaka (#3396) +- Adjusted ProductVideo props to right names - @przspa (#3263) +- Fixed Doubled SKU row in compare tab - @manvendra-singh1506 (#3447) +- Fixed warning in product details because of duplicate `product` property in `AddToCompare` mixin - @cewald (#3428) +- Fixed adding unconfigured product to cart from homepage - @lukeromanowicz (#3512) +- Fixed "Clear Wishlist" Button - @dz3n (#3522) +- Fixed hash in dynamically resolved urls causing resolving issues - @lukeromanowicz (#3515) +- Fix invalid routes in ButtonOutline and ButtonFull - @lukeromanowicz (#3541, #3545) +- Fix adding notification with 'hasNoTimeout' after normal notification - @gibkigonzo (#3465) +- Logged-in user's shipping address on checkout page - @przspa (#2636) +- Fix for the "add to cart" test +- Fixed error with dayjs when locale is 2-digit (without a '-') @rain2o (#3581) +- Fix applying coupon - @andrzejewsky (#3578) +- Prevent caching storage instance in plugin module scope - @gibkigonzo (#3571) +- Fixed incorrect image sizes in related section on product page - @andrzejewsky (#3590) +- Fix typo on default language - @lorenaramonda (#3076) +- Remove race condition while loading locale messages - @gibkigonzo (#3602) +- Fix displaying same country twice in the in the country switcher - @andrzejewsky (#3587) +- Fixed resolving store code on SSR - @andrzejewsky (#3576) +- Clear user data if error occurs while login - @gibkigonzo (#3588) +- Fix loading bestsellers on 404 error page - @andrzejewsky (#3540) +- Remove modifying config by reference in multistore - @gibkigonzo (#3617) +- Add translation key for add review - @gibkigonzo (#3611) +- Add product name prop to reviews component - @gibkigonzo (#3607) +- Show default cms pages when current store code is not equals to default - @andrzejewsky (#3579) +- Fix login errors with mailchimp - @gibkigonzo (#3612) +- Hydration error on homepage - @patzick (#3609) +- Fix adding products with custom options - @andrzejewsky (#3597) +- check silentMode in errors on the same level as task.silent - @gibkigonzo (#3621) +- Add missing parameters (`size`,`start`) to `quickSearchByQuery()` in `attribute/list` action - @cewald (#3627) +- Fix breadcrumb homepage link in cms static pages - @andrzejewsky (#3631) +- Fixed special price that can break when you change pages (browser navigation for/back) or just go from category to product page - @resubaka (#3638) +- Fixed problem with losing browser history - @andrzejewsky (#3642) +- Fixed wrong links on the static pages - @andrzejewsky (#3659) +- Fixed problem with changing quantity in offline mode on product page - @andrzejewsky (#3662) +- Fixed problem with extending storeView configuration - @andrzejewsky (#3655) +- Removed infinite loop when changing checkbox in shipping details - @gibkigonzo (#3656) +- Fixed displaying single order in the profile - @andrzejewsky (#3663) +- Make microcart ui consistent for all types of products - @gibkigonzo (#3673) +- Fixed missing storeCode in metaInfo - @andrzejewsky (#3674) +- Removed showing popup when you have just logged out - @andrzejewsky (#3680) + +### Changed / Improved + +- Change Product quantity field validation - @jakubmakielkowski (#3560) +- Update confirmation page in offline mode - @jakubmakielkowski (#3100) +- Removed server order id from ThankYouPage - @federivo (#3480) +- Shipping address is saved as default when not logged in user chooses to create account during checkout - @iwonapiotrowska (#2636) +- The `attribute.list_by_id` and `attribute.list_by_code` from the `window.__INITIAL_STATE__` which could be even up to 50% of the product page size. - @pkarw (#3281) +- Can set transition style for Modal content - @grimasod (#3146) +- Added stock to cart items - @cheeerd (#3166) +- Moves theme specific stores and components into themes - @michasik (#3139) +- Decreased the `localStorage` quota usage + error handling by introducing new config variables: `config.products.disablePersistentProductsCache` to not store products by SKU (by default it's on). Products are cached in ServiceWorker cache anyway so the `product/list` will populate the in-memory cache (`cache.setItem(..., memoryOnly = true)`); `config.seo.disableUrlRoutesPersistentCache` - to not store the url mappings; they're stored in in-memory cache anyway so no additional requests will be made to the backend for url mapping; however it might cause some issues with url routing in the offline mode (when the offline mode PWA installed on homescreen got reloaded, the in-memory cache will be cleared so there won't potentially be the url mappings; however the same like with `product/list` the ServiceWorker cache SHOULD populate url mappings anyway); `config.syncTasks.disablePersistentTaskQueue` to not store the network requests queue in service worker. Currently only the stock-check and user-data changes were using this queue. The only downside it introuces can be related to the offline mode and these tasks will not be re-executed after connectivity established, but just in a case when the page got reloaded while offline (yeah it might happen using ServiceWorker; `syncTasks` can't be re-populated in cache from SW) - @pkarw (#3180) +- Translation file improvements - @vishal-7037 (#3198) +- Added configuration for max attempt task & cart by pass - @cnviradiya (#3193) +- Added catching of errors when ES is down - @qiqqq +- Added debounce for updating quantity method in the cart - @andrzejewsky (#3191) +- New modules API and rewrite - @filrak, @JCown (#3144) +- Refactored the vuex user module - @andrzejewsky (#3095) +- Brazilian Portuguese (pt_BR) translation improved - @pxfm (#3288) +- Moved store/lib to /lib - @pxfm (#3253) +- Corrected usage of "configurableChildrenStockPrefetchStatic" setting, refactored logic to tested helper - @philippsander (#859) +- Improved some of the german translations in spelling and wording - @MariaKern (#3297) +- Added lazy-hydrate for category products - @andrzejewsky (#3327) +- Refactored vuex order module - @andrzejewsky (#3337) +- Changed body no-scroll behavior for overlapped element - @przspa (#3363) +- `config.dynamicConfigReload` option should use deep copy for `Object.assign()` - @cewald (#3372) +- Add translation for the defaultTitle - @cnviradiya (#3282) +- Refactored vuex tax module - @andrzejewsky (#3337) +- Refactored vuex stock module - @andrzejewsky (#3337) +- Removed extra unnecessary code from BaseInputNumber - @cnviradiya (#3410) +- Refactored vuex checkout module - @andrzejewsky (#3337) +- Moved my-account authentication guard to MyAccount core page - @przspa (#3325) +- Refactored vuex compare module - @andrzejewsky (#3337) +- Refactored vuex whishlist module - @andrzejewsky (#3337) +- Refactored vuex cms module - @andrzejewsky (#3337) +- Refactored vuex review module - @andrzejewsky (#3337) +- Refactored vuex newsletter module - @andrzejewsky (#3337) +- Changed type of Id fields related to product, category and attribute to support numeric as well as string - @adityasharma7 (#3456) +- Optimized fetching product data on homepage - @lukeromanowicz (#3512) +- `localizedRoute()` now supports path (and prefers over fullPath) in LocalizedRoute objects - @lukeromanowicz (#3515) +- Move setting review_status from VSF to VSF-API - @afirlejczyk +- `localizedRoute()` doesn't return urlDispatcher routes anymore. Use localizedDispatcherRoute instead - @lukeromanowicz (#3548) +- Improved scrolling in Safari on iOS devices (sidebars) - @phoenixdev-kl (#3551) +- Improved cookie and offline badges (z-index, overflow) - @phoenixdev-kl (#3552) +- Improved translations: Replaced concatenations with "named formatting" (see http://kazupon.github.io/vue-i18n/guide/formatting.html#named-formatting) - @phoenixdev-kl (#3550) +- Added `filterMinimumShouldMatch` to ES queries in order to support ES7 - @pkarw (#1692) +- Pass `RouteManager` as proxy for router.addRoutes - @gibkigonzo (#3479) +- Added generic types to hooks - @gibkigonzo +- Change sku to string when checking products equality - @gibkigonzo (#3606) +- Pass to `registerModule` all parameters as one object - @gibkigonzo (#3634) +- Include shipping address data in request for shipping methods for more accurate filtering - @rain2o (#2515) +- remove 'disabled' flag in storeViews config - @gibkigonzo (#3659) ## [1.10.5] - 28.11.2019 ### Fixed @@ -72,6 +349,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.10.0] - 2019.08.10 ### Added + +- Cast cart_id as string - Order schema expects string, Magento does not generate a string as cart id in every case - @DaanKouters (#3097) - Make installer work for windows - @Flyingmana (#2616) - "Clear cart" button in the cart - @jablpiotrek (#2587) - Global config api path under `api.url` - @BartoszLiburski (#2622) @@ -92,9 +371,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - OrderNumber on ThankYouPage - @Flyingmana (#2743) ### Removed -- The getter `cart/totals` has been replaced with `cart/getTotals` - @pkarw (#2522) -- The getter `cart/coupon` has been replaced with `cart/getCoupon` - @pkarw (#2522) -- The getter `cart/totalQuantity` has been replaced with `cart/getItemsTotalQuantity` - @pkarw (#2522) + +- The getter `cart/totals` has ben replaced with `cart/getTotals` - @pkarw (#2522) +- The getter `cart/coupon` has ben replaced with `cart/getCoupon` - @pkarw (#2522) +- The getter `cart/totalQuantity` has ben replaced with `cart/getItemsTotalQuantity` - @pkarw (#2522) - The event `cart-before-save` has been removed - @pkarw (#2522) - The action `cart/save` has been removed - @pkarw - (#2522) - Some deprecated config options: `useShortCatalogUrls` and `setupVariantByAttributeCode` have been removed - @pkarw (#2915) @@ -103,6 +383,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Make all links with the primary color - @hackbard (#2932) ### Fixed + - Back button on the Error page has been fixed - @pkarw (#3077) - Special price got zeroed - @pkarw (#2940) - Microcart tax + discount totals fix - @pkarw (#2892) @@ -154,6 +435,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Clear compare list after logout - @patzick (#3348) ### Changed / Improved + - The `cart/sync`, `cart/addItems`, `cart/removeItem` and `cart/updateQuantity` now returns the `diffLog` object with all the notifications, server statuses and items changed during the shopping cart sync - The `cart/addItem` is no longer displaying the error messages - please use the `diffLog.clientNorifications` to update the UI instead (take a look at the `AddToCart.ts` for a reference) - The action `cart/userAfterLoggedin` got renamed to `cart/authorize` - @pkarw (#2522) @@ -165,7 +447,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Disabled the server cart sync in case user is in the checkout - @pkarw (#2749) - Improved ProductGalleryCarousel component to handle nonnumeric options id’s - @danieldomurad (#2586) - Number of displayed products is now visible on PLP on desktop - @awierzbiak (#2504) -- Improved visibility of product SKU in wishlist - @PanMisza (#2606) +- Improved visibility of product SKU in wishlist - @PanMisza (#2606) - Instant focus to search input field after click on search icon in navbar - @ca1zr (#2608) - Login flow from authorized pages after session expired, show the modal with new error message and redirect after login - @gdomiciano, @natalledm (#2674) - Added support for the newest node version - @gdomiciano (#2669) @@ -209,7 +491,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.9.2] - 2019.06.10 ### Fixed -- Instant Checkout visible on Safari - @przspa (#2991) + +- Instant Checkout visible on Safari - @przspa (#2991) - Search Sidebar on Safari - @przspa (#2990) - Country label style - @przspa (#2989) - BaseInputNumber for qty of the product in the cart can change by using arrows - @przspa (#2988) @@ -220,6 +503,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.9.1] - 2019.05.27 ### Fixed + - Remove security vulnerabilities by updating project dependencies - @patzick (#2942) - Fix Configurable Products not accessible in CSR when children visibility is set to "not visible individually" - @revlis-x (#2933) - ProductTile placeholders are visible on SSR - @patzick (#2939) @@ -227,18 +511,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.9.0] - 2019.05.06 ### Added + - The Url Dispatcher feature added for friendly URLs. When `config.seo.useUrlDispatcher` set to true the `product.url_path` and `category.url_path` fields are used as absolute URL addresses (no `/c` and `/p` prefixes anymore). Check the latest `mage2vuestorefront` snapshot and reimport Your products to properly set `url_path` fields - #2010 - @pkarw - Unit tests of cart module written in jest - @lukeromanowicz (#2305) - validation for UTF8 alpha and alphanumeric characters in most checkout fields - @lromanowicz (#2653) - helper to process config urls with default endpoint host `config.api.host` - @patzick (#2858) ### Changed / Improved + - The `core/helpers` parsing URL methods exchanged to `query-string` package - @pkarw (#2446) - Unit tests in Karma are now removed in favor of jest - @lukeromanowicz (#2305) - Material Icons are loaded asynchronously - @JKrupinski, @filrak (#2060) - Update to babel 7 - @lukeromanowicz (#2554) ### Fixed + - For first time setup of the SSR Cache, a local cache-version.json file is required. The path has been removed from .gitignore and a template has been added. - @rio-vps - Gallery low quality image in offline mode when high quality already cached - @patzick (#2557) - Payment issue when no address set - @szafran89 (#2593) @@ -272,28 +559,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.8.5] - 2019-04-17 ### Fixed + - Memory leaks on SSR with Vue.use - @patzick (#2745) ## [1.8.4] - 2019-03-26 ### Fixed + - Problem with incomplete category products load for offline use - @patzick (#2543) - Category products view crash on scrolling down in offline mode - @patzick (#2569) -- Default propery issue for the col-xs-* classes - @cnviradiya (#2558) +- Default propery issue for the col-xs-\* classes - @cnviradiya (#2558) - Wishlist and compare list not cached properly - @filrak (#2580) ### Changed / Improved + - Category and Homepage products are now cached for offline use on SSR entry - @patzick (@1698) ## [1.8.3] - 2019-03-03 ### Added + - Payment Request API integration - @qiqqq (#2306) - New reactive helper to check online state. Usage: `import { onlineHelper } from '@vue-storefront/core/helpers'` and then `onlineHelper.isOnline` - @patzick (#2510) - Cart count config, allows you to display the item count instead of a sum of the item quantities - @pauluse (#2483) - Video support in Product Gallery component. - @rain2o (#2433) ### Fixed + - Problem with placing second order (unbinding payment methods after first order) - @patzick (#2195, #2503) - Remaking order on user orders page - @patzick (#2480) - Images blinking on category page - @pkarw (#2523) @@ -302,6 +594,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Non-integer qty of product added to the cart - @pkarw (#2517) ### Changed / Improved + - Fixed an issue where the correct image for a product configuration wasn't set on the product page image carousel. Also added the fix on the productcarousel in the zoom component - @DaanKouters (#2419) - Way of creating VS Modules was changed to use factory method instead of explict object creation. - @filrak (#2434) - Added clear filters button on desktop also and only show if filters are applied - @DaanKouters (#2342) @@ -331,23 +624,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed editing mode from My Newsletter section - @aniamusial (#2766) - Clicking Remake order now adds your items and redirects you to the checkout - @mikesheward (#2710) - ### Deprecated / Removed + - `@vue-storefront/store` package deprecated - @filrak ## [1.8.2] - 2019-02-11 + - Fixed docker-compose configuration for network_mode and TS build config - @lukeromanowicz (#2415) ## [1.8.1] - 2019-02-10 + This is hot-fix release for fixing the payment methods switching issue when both: `payments-cash-on-delivery` and `payments-backend-methods` modules enabled. ### Changed / Improved - - Fixed doubled invlication of `placeOrder` when both: `payments-cash-on-delivery` and `payments-backend-methods` modules enabled - #2405 + +- Fixed doubled invlication of `placeOrder` when both: `payments-cash-on-delivery` and `payments-backend-methods` modules enabled - #2405 ## [1.8.0] - 2019-02-07 + Additional migration tips are available [here](https://github.com/DivanteLtd/vue-storefront/blob/master/docs/guide/upgrade-notes/README.md). ### Added + - Chinese translation added - @wadereye (#2265) - Categories filter in search view - @kjugi, @patzick (#1710) - AsyncDataLoader feature - @pkarw (#2300) @@ -360,6 +658,7 @@ Additional migration tips are available [here](https://github.com/DivanteLtd/vue - Hotjar integration - @lukeromanowicz (#840) ### Changed / Improved + - Theme structure improvements - @filrak (#2223) - Type interfaces and refactor - @filrak (#2227, #2267) - Changed beforeRegistration and afterRegistration hooks signature. Now it contains only one object VSF. The subfields are the same as before so changing `beforeRegistration( Vue, config, store, isServer )` to `beforeRegistration({ Vue, config, store, isServer })`(and same with `afterRegistration`) is enough to make a proper migration to new API. - @filrak (#2330) @@ -375,7 +674,7 @@ Additional migration tips are available [here](https://github.com/DivanteLtd/vue - `store/lib/search` has been moved to `core/lib/search` - @lukeromanowicz (#2225) - `store/lib/multistore` has been moved to `core/lib/multistore` - @lukeromanowicz (#2224) - BaseSelect syntax improvements - @jszczech (#2237) -- Optional cart discounts display on side cart - @mcspronko (#1758) +- Optional cart discounts display on side cart - @mcspronko (#1758) - Special price dates checking - backport of @igloczek's (#2245) - Category filters reset functionality on mobile - @vue-kacper, @patzick, @renatocason (#2262) - Improve sortBy mobile view - @martaradziszewska (#2251) @@ -408,12 +707,16 @@ Additional migration tips are available [here](https://github.com/DivanteLtd/vue - Configurable options attribute descriptor - @pkarw (#2384) ## [1.7.3] - 2019-01-31 + ### Fixed + - Output cache between build, cache versioning added - @igloczek (#2309) - Missing `no-ssr` wrapper around user specific content, which leads to broken app in production mode - @igloczek (#2314) ## [1.7.2] - 2019-01-28 + ### Fixed + - clear search filters on mobile - @patzick (#2282) - SSR problem on checkout page on reload - @vue-kacper (#2220) - Improved offline mode handlers - @pkarw (#2217) @@ -423,9 +726,11 @@ Additional migration tips are available [here](https://github.com/DivanteLtd/vue - SSR detection in components - @patzick (#2173) ### Added + - Hotjar extension (#840) ### Changed + - compress banner images - @patzick (#2280) - Dynamic attributes loader (#2137) - Dynamic categories prefetching (#2076) @@ -433,16 +738,21 @@ Additional migration tips are available [here](https://github.com/DivanteLtd/vue - Support regional characters in urls - Backport of @aekal's (#2243) ### Added + - Translations of banners - @patzick (#2276) - Banners title background on mobile - @patzick (#2272) - New main site look - @patzick (#2266) ## [1.7.1] - 2019-01-15 + ### Fixed + - Corrected scrolled sidebar menu position ## [1.7.0] - 2019-01-15 + ### Added + - Dynamic categories prefetching — @pkarw #2100 - Per-route codesplitting for SSR pages — @patzick #2068 - async/await support — @patzick #2092 @@ -456,6 +766,7 @@ Additional migration tips are available [here](https://github.com/DivanteLtd/vue - Out of the box GZIP compression and sourcemap removal in prod mode — @patzick #2186 ### Changed / Improved + - Invalidate output cache using POST - @Cyclonecode #2084 - NGNIX installation improvements for docs — @janmyszkier #2080 - HTML semantics improvements — @patzick #2094 @@ -472,6 +783,7 @@ Additional migration tips are available [here](https://github.com/DivanteLtd/vue - IndexedDb changed to LocalStorage + ServiceWorker native caching (#2112) ### Fixed + - Fix Notification.vue compiling issue on prod - @ladrua #2079 - Fix wishlist toggle bug — @shkodasv #2086 - findConfigurableChildAsync — fix checking stock for configurable child — @afirlejczyk #2097 @@ -493,19 +805,24 @@ Additional migration tips are available [here](https://github.com/DivanteLtd/vue - Fix Cart Configurable Item pulled from Magento loaded as Simple — @pkarw @valeriish #2169 #2181 ### Depreciated + - extendStore depreciation - @filrak #2143 - ValidationError class depreciation - @filrak #2143 ## [1.6.0] - 2018-12-05 + ### Added + - Lazy loading for SSR and non-SSR routes - app splitted into modules ### Removed + - `vsf-payment-stripe` module integration removed from core ### Changed -- There is new config option `config.orders.directBackendSync` that changes the behavior of placing an order. Please do read [more on this change](https://github.com/DivanteLtd/vue-storefront/commit/e73f2ca19a5d33a39f8b0fd6346543eced24167e) and [more on vue-storefront-api change](https://github.com/DivanteLtd/vue-storefront-api/commit/80c497f72362c72983db4fdcac14c8ba6f8729a8) + +- There is new config option `config.orders.directBackendSync` that changes the behavior of placing an order. Please do read [more on this change](https://github.com/DivanteLtd/vue-storefront/commit/e73f2ca19a5d33a39f8b0fd6346543eced24167e) and [more on vue-storefront-api change](https://github.com/DivanteLtd/vue-storefront-api/commit/80c497f72362c72983db4fdcac14c8ba6f8729a8) - ProductSlider, ProductLinks, ProductListing moved to theme. - Many theme-related logic moved to theme (+ deleted empty core components just with `name`) - Components required for backward compatibility moved to `compatibility` folder. For all this files you just need to add `compatibility` after `core` in import path to make them work like before. @@ -520,6 +837,7 @@ Additional migration tips are available [here](https://github.com/DivanteLtd/vue ## [1.5.0] - 2018-10-22 ### Added + - Contact form mailer - #1875 - Akbar Abdrakhmanov @akbarik - oauth2 configuration in setup - #1865 - Krister Andersson @Cyclonecode - GraphQL schema extendibility in the API - Yoann Vié @@ -528,9 +846,11 @@ Additional migration tips are available [here](https://github.com/DivanteLtd/vue - 'Apply' filters button on mobile category - #1709 - Damian Fiałkiewicz @Aekal ### Changed + - New Modules API, and base modules (cart, wishlist, newsletter ...) refactored [read more...](https://github.com/DivanteLtd/vue-storefront/blob/master/doc/api-modules/about-modules.md) - Filip Rakowski @filrak ### Fixed + - The `regionId` field added to Order interface - #1258 - Jim Hil @jimcreate78 - SSR Memory leaks fixed - #1882 Tomasz Duda @tomasz-duda - E2E tests fixed - #1861 - Patryk Tomczyk @patzik @@ -542,6 +862,7 @@ Additional migration tips are available [here](https://github.com/DivanteLtd/vue ## [1.4.0] - 2018-10-05 ### Added + - GraphQL support - #1616 - Yuri Boyko @yuriboyko, Vladimir Plastovets @VladimirPlastovets => [PHOENIX MEDIA](https://www.phoenix-media.eu/) - Layout switching + Advanced output mechanisms - #1787 - Piotr Karwatka @pkarw - Dynamic config reload - #1800 - Piotr Karwatka @pkarw @@ -552,9 +873,11 @@ Additional migration tips are available [here](https://github.com/DivanteLtd/vue - Console silent mode (disabled by default) - #1752 - Piotr Karwatka - @pkarw ### Changed + - Please check the [Upgrade notes](https://github.com/DivanteLtd/vue-storefront/blob/develop/doc/Upgrade%20notes.md) for the full list ### Fixed + - `docker-compose.yml` files updated - @kovinka - Non-core translations moved to theme resource files (i18n) - #1747 - David Rouyer @DavidRouyer - Non-core assets moved to the theme - #1739, #1740 - David Rouyer @DavidRouyer @@ -568,6 +891,7 @@ Additional migration tips are available [here](https://github.com/DivanteLtd/vue ## [1.3.0] - 2018-08-31 ### Added + - TypeScript support - please check [TypeScript Action Plan](https://github.com/DivanteLtd/vue-storefront/blob/master/docs/guide/basics/typescript.md) for details - New `core/modules` added regarding the [Refactor to modules plan](https://github.com/DivanteLtd/vue-storefront/blob/master/doc/api-modules/refactoring-to-modules.md) - Price tier's support #1625 @@ -577,15 +901,18 @@ Additional migration tips are available [here](https://github.com/DivanteLtd/vue - dynamic port allocation #1511 ### Removed + - unused `libs`, `components`, `core/api/cart` webpack aliases - `global.$VS` has been replaced with `rootStore` #1624 ### Changed + - `core` directory is now a `@vue-storefront/core` package, webpack alias and all related imports reflect this change [#1513] - `core/api` renamed to `core/modules`, mixin features moved to `core/modules/module_name/features` - `core/lib/i18n` moved into separate `@vue-storefront/i18n` package ### Fixed + - installer paths are now normalized (to support paths including spaces) #1645 - status check added to the configurable_children products #1639 - product info update when clicking the related products #1601 @@ -600,12 +927,14 @@ Additional migration tips are available [here](https://github.com/DivanteLtd/vue ## [1.2.0] - 2018-08-01 ### Fixed + - Improved integration tests [#1471] - Minor taxcalc.js improvements [#1467] - Search by SKU fixed [#1455] - ProductList dbl click fix [#1438] ### Added + - Docker support for vue-storefront - Production config docs added [#1450] - Integration tests for Compare products added [#1422] @@ -618,6 +947,7 @@ Additional migration tips are available [here](https://github.com/DivanteLtd/vue Please keep an eye on the **[UPGRADE NOTES](https://github.com/DivanteLtd/vue-storefront/blob/master/doc/Upgrade%20notes.md)** ### Fixed + - Zip Code validation [#1372] - Get inpspired block [#968] - Favicon [#836] @@ -629,6 +959,7 @@ Please keep an eye on the **[UPGRADE NOTES](https://github.com/DivanteLtd/vue-st - IndexedDB locking issue ### Added + - Added PM2 process manager [#1162] - Added billing data phone number support [#1338] - Added validation labels + generic control for CountrySelector [#1227] @@ -640,47 +971,61 @@ Please keep an eye on the **[UPGRADE NOTES](https://github.com/DivanteLtd/vue-st - Production ready Docker config for vue-storefront-api ## [1.0.5] - 2018-06-04 + ### Fixed + - Shipping region fix - Hotfix for missing config.storeViews.multistore check - Minor fixes ## [1.0.4] - 2018-06-02 + ### Fixed + - defaultCountry fix for IT - Tax classes hotfix - tax_class_id is required by taxcalc - restored along with version inc - Minor fixes ## [1.0.3] - 2018-06-02 + ### Fixed + - Minor fixes ## [1.0.2] - 2018-06-02 + ### Fixed + - vue-storefront-stripe renamed to vsf-payment-stripe hotfix - Minor fixes ## [1.0.1] - 2018-05-31 + ### Fixed + - Minor fixes ## [1.0.0] - 2018-05-30 + ### Added -- __Multistore__ - now it's possible to manage the store views with all the features like translations, custom category, and products content, shipping rates - basically all Magento2 features are supported! You can read more on how to setup Multistore here. -- __Bundle products__ - support for the Magento-like bundle products with all the custom options, pricing rules etc. -- __Configurable options__ - that allows users to select radio/checkbox options + put some custom notes (textboxes) on the products they like to order, -- __Crossell, Upsell, Related products__ - are now synchronized with Magento2, -- __Webpack4 support__ - we've migrated from Webpack2 -> Webpack4 and now the build process takes much less time while providing some cool new features, -- __Core components refactor__ - without changing APIs, we've stripped the core components from s to improve the performance and improve the code readability, -- __PWA Manifest fixes__ - iOS PWA support required us to adjust some settings, -- __Improved translations__ - we're constantly tweaking the translation files :) We've just added it-IT and pl-PL (finally!) support recently -- __Improved Travis-CI pipeline__ - and added support for end-2-end testing, -- __Lot of bugfixes + UX fixes__ - countless hours spent on improving the code and UI quality! -- __Please check it out:__ visit: https://demo.vuestorefront.io/ + +- **Multistore** - now it's possible to manage the store views with all the features like translations, custom category, and products content, shipping rates - basically all Magento2 features are supported! You can read more on how to setup Multistore here. +- **Bundle products** - support for the Magento-like bundle products with all the custom options, pricing rules etc. +- **Configurable options** - that allows users to select radio/checkbox options + put some custom notes (textboxes) on the products they like to order, +- **Crossell, Upsell, Related products** - are now synchronized with Magento2, +- **Webpack4 support** - we've migrated from Webpack2 -> Webpack4 and now the build process takes much less time while providing some cool new features, +- **Core components refactor** - without changing APIs, we've stripped the core components from s to improve the performance and improve the code readability, +- **PWA Manifest fixes** - iOS PWA support required us to adjust some settings, +- **Improved translations** - we're constantly tweaking the translation files :) We've just added it-IT and pl-PL (finally!) support recently +- **Improved Travis-CI pipeline** - and added support for end-2-end testing, +- **Lot of bugfixes + UX fixes** - countless hours spent on improving the code and UI quality! +- **Please check it out:** visit: https://demo.vuestorefront.io/ ## [1.0.0-rc.3] - 2018-04-29 + ### Added + - Performance tweaks: improved service worker config, reduced JSONs, two-stage caching, - User token auto refresh, - My Account fixes @@ -693,7 +1038,9 @@ Please keep an eye on the **[UPGRADE NOTES](https://github.com/DivanteLtd/vue-st - Product and Category page refactoring ## [1.0.0-rc.2] - 2018-03-29 + ### Added + - Basic Magento 1.9 support, - Translations: ES, DE, NL, FR - Lerna support for managing the npm packages within the one repository, @@ -706,7 +1053,9 @@ Please keep an eye on the **[UPGRADE NOTES](https://github.com/DivanteLtd/vue-st - Other fixes. ## [1.0.0-rc.0] - 2018-03-01 + ### Added + - i18n (internationalization) support for the UI, - Support for Magento2 dynamic cart totals - which enables the shopping cart rules mechanism of Magento to work with VS, - ESlint-plugin-vue installed, @@ -720,7 +1069,9 @@ Please keep an eye on the **[UPGRADE NOTES](https://github.com/DivanteLtd/vue-st - Droppoints shipping methods (NL support) added. ## [0.4.0] - 2018-01-28 + ### Added + - Improved theming support + B2B product catalog theme included (original github repo); it's PoC made in just one week! Isn't it amazing that you can customize VS in just one week to this extent? :) - Pimcore support (more on this, github repo) - Customer's dashboard + address book, integration with Checkout @@ -735,7 +1086,9 @@ Please keep an eye on the **[UPGRADE NOTES](https://github.com/DivanteLtd/vue-st - Lot of smaller tweaks ## [0.3.0] - 2017-12-21 + ### Added + - Bundle products support, - Tax calculation regarding the Magento's logic for different rates per countries, states. - User registration, log-in, password reset @@ -753,7 +1106,9 @@ Please keep an eye on the **[UPGRADE NOTES](https://github.com/DivanteLtd/vue-st - Updated installer with support for Linux and MacOSX ## [0.2.1-alpha.0] - 2017-11-16 + ### Added + - Homepage - Category page - Product page @@ -768,5 +1123,7 @@ Please keep an eye on the **[UPGRADE NOTES](https://github.com/DivanteLtd/vue-st - RWD (except some checkout issues to be fixed) ## [0.2.0-alpha.0] - 2017-11-15 + ### Fixed + - Lazy loaded blocks size fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 062483214..00130e0de 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,11 +1,11 @@ # How to Contribute -Already a JS/Vue.js developer? Pick an issue, push a PR and instantly become a member of the vue-storefront contributors community. +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! 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) +Before you type an issue please read about out [release lifecycle](https://docs.vuestorefront.io/guide/basics/release-cycle.html). # Branches @@ -18,27 +18,25 @@ The main branches used by the core team are: Please use "develop" or "RC" for development purposes as the "master" can be merged just as the new release is coming out (about once a month)! -## Issue reporting guidelines: +## Issue Reporting Guidelines Always define the type of issue: * Bug report * Feature request -While writing issues, be as specific as possible -All requests regarding support with implementation or application setup should be sent to contributors@vuestorefront.io +While writing issues, be as specific as possible. All requests regarding support with implementation or application setup should be sent to contributors@vuestorefront.io. -**Tag your issues properly**. If you found a bug tag it with `bug` label. If you're requesting new feature tag it with `feature request` label. +**Tag your issues properly**. If you found a bug, tag it with `bug` label. If you're requesting new feature, tag it with `feature request` label. -## Git flow -We're introducing TypeScript to Vue Storefront core, so You can use it where it's appropriate - but please be pragmatic. -Here are some thoughts on how to use TS features in Vue Storefront: [TypeScript Action Plan](https://github.com/DivanteLtd/vue-storefront/blob/master/doc/TypeScript%20Action%20Plan.md). +## Git Flow -## Pull request Checklist +We're introducing TypeScript to Vue Storefront core, so you can use it where it's appropriate - but please be pragmatic. +Here are some thoughts on how to use TypeScript features in Vue Storefront: [TypeScript Action Plan](https://github.com/DivanteLtd/vue-storefront/blob/master/doc/TypeScript%20Action%20Plan.md). -Here’s how to submit a pull request. Pull request that don't meet these requirements will not be merged. +## Pull Request Checklist **ALWAYS** use [Pull Request template](https://github.com/DivanteLtd/vue-storefront/blob/master/PULL_REQUEST_TEMPLATE.md) it's automatically added to each PR. -1. Fork the repository and clone it locally fro the 'develop' branch. Make sure it's up to date with current `develop` branch +1. Fork the repository and clone it locally from the 'develop' branch. Make sure it's up to date with current `develop` branch 2. Create a branch for your edits. Use the following branch naming conventions: * bugfix/task-title * feature/task-name @@ -46,13 +44,13 @@ Here’s how to submit a pull request. Pull request that don't meet these req 4. Reference any relevant issues or supporting documentation in your PR (ex. “Issue: 39. Issue title.”). 5. If you are adding new feature provide documentation along with the PR. Also, add it to [upgrade notes](https://github.com/DivanteLtd/vue-storefront/blob/master/doc/Upgrade%20notes.md) 6. If you are removing/renaming something or changing its behavior also include it in [upgrade notes](https://github.com/DivanteLtd/vue-storefront/blob/master/doc/Upgrade%20notes.md) -7. Test your changes! Run your changes against any existing tests and create new ones when needed. Make sure your changes don’t break the existing project. Make sure that your branch is passing Travis CI build. +7. Test your changes! Run your changes against any existing tests and create new ones when needed. Make sure your changes don’t break the existing project. Make sure that your branch is passing Travis CI build. 8. If you have found a potential security vulnerability, please DO NOT report it on the public issue tracker. Instead, send it to us at contributors@vuestorefront.io. We will work with you to verify and fix it as soon as possible. (https://github.com/DivanteLtd/vue-storefront/blob/master/README.md#documentation--table-of-contents)) -## Acceptance criteria +## Acceptance Criteria Your pull request will be merged after meeting following criteria: - Everything from "Pull Request Checklist" -- PR is proposed to appropriate branch +- PR is proposed to appropriate branch - There are at least two approvals from core team members diff --git a/README.md b/README.md index 15c50684e..941c6d436 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # Vue Storefront - headless PWA for any eCommerce build:passed -![version](https://img.shields.io/badge/node-v8.x-blue.svg) +![version](https://img.shields.io/badge/node-v10.x-blue.svg) ![Branch stable](https://img.shields.io/badge/stable%20branch-master-blue.svg) ![Branch Develop](https://img.shields.io/badge/dev%20branch-develop-blue.svg) ![Branch Develop](https://img.shields.io/badge/community%20chat-slack-FF1493.svg) @@ -126,7 +126,7 @@ You can find some tutorials and explanations on our [YouTube channel](https://ww - [Configuration file explained](https://docs.vuestorefront.io/guide/basics/configuration.html) - [Vue Storefront Modules](https://docs.vuestorefront.io/guide/modules/introduction.html) - [Contribution and issue reporting guideness](https://docs.vuestorefront.io/guide/basics/contributing.html) -- [FAQ / Receipes](https://docs.vuestorefront.io/guide/basics/recipes.html#problem-starting-docker-while-installing-the-vue-storefront) +- [FAQ / Recipes](https://docs.vuestorefront.io/guide/basics/recipes.html#problem-starting-docker-while-installing-the-vue-storefront) - [Feature list](https://docs.vuestorefront.io/guide/basics/feature-list.html) - [TypeScript Action Plan](https://docs.vuestorefront.io/guide/basics/typescript.html) - [GraphQL Action Plan](https://docs.vuestorefront.io/guide/basics/graphql.html) @@ -344,7 +344,7 @@ Vue Storefront is a Community effort brought to You by our great Core Team and s alt="Phoenix Media" width="150" > - + @@ -353,7 +353,7 @@ Vue Storefront is a Community effort brought to You by our great Core Team and s alt="Absolute Web Services" height="50" > - + @@ -581,7 +581,7 @@ Vue Storefront is a Community effort brought to You by our great Core Team and s > - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + Partners are encouraged to support the project in various ways - mostly by contributing the source code, marketing activities, evangelizing and of course - implementing the production projects. We do support our partners by dedicated contact channels, workshops and by sharing the leads from merchants interested in implementations. diff --git a/config/default.json b/config/default.json index 41ae29c79..c11151aa3 100644 --- a/config/default.json +++ b/config/default.json @@ -1,490 +1,531 @@ { - "server": { - "host": "localhost", - "port": 3000, - "protocol": "http", - "api": "api", - "devServiceWorker": false, - "useOutputCacheTagging": false, - "useOutputCache": false, - "outputCacheDefaultTtl": 86400, - "availableCacheTags": ["product", "category", "home", "checkout", "page-not-found", "compare", "my-account", "P", "C", "error"], - "invalidateCacheKey": "aeSu7aip", - "dynamicConfigReload": false, - "dynamicConfigContinueOnError": false, - "dynamicConfigExclude": ["ssr", "storeViews", "entities", "localForage", "shipping", "boost", "query"], - "dynamicConfigInclude": [], - "elasticCacheQuota": 4096, - "ssrDisabledFor": { - "extensions": [".png", ".gif", ".jpg", ".jpeg", ".woff", ".eot", ".woff2", ".ttf", ".svg", ".css", ".js", ".json", ".ico", ".tiff", ".tif", ".raw"] - } - }, - "seo": { - "useUrlDispatcher": true, - "disableUrlRoutesPersistentCache": true - }, - "console": { - "showErrorOnProduction" : false, - "verbosityLevel": "display-everything" - }, - "redis": { - "host": "localhost", - "port": 6379, - "db": 0 - }, - "graphql":{ - "host": "localhost", - "port": 8080 - }, - "api": { - "url": "http://localhost:8080" - }, - "elasticsearch": { - "httpAuth": "", - "host": "/api/catalog", - "index": "vue_storefront_catalog", - "min_score": 0.02, - "csrTimeout": 5000, - "ssrTimeout": 1000, - "queryMethod": "GET", - "disablePersistentQueriesCache": true, - "searchScoring": { - "attributes": { - "attribute_code": { - "scoreValues": { "attribute_value": { "weight": 1 } } - } - }, - "fuzziness": 2, - "cutoff_frequency": 0.01, - "max_expansions": 3, - "minimum_should_match": "75%", - "prefix_length": 2, - "boost_mode": "multiply", - "score_mode": "multiply", - "max_boost": 100, - "function_min_score": 1 - }, - "searchableAttributes": { - "name": { - "boost": 4 - }, - "sku": { - "boost": 2 - }, - "category.name": { - "boost": 1 - } - } - }, - "ssr": { - "templates": { - "default": "dist/index.html", - "minimal": "dist/index.minimal.html", - "basic": "dist/index.basic.html", - "amp": "dist/index.amp.html" - }, - "executeMixedinAsyncData": true, - "initialStateFilter": ["__DEMO_MODE__", "version", "storeView"], - "useInitialStateFilter": true - }, - "defaultStoreCode": "", - "storeViews": { - "multistore": false, - "commonCache": true, - "mapStoreUrlsFor": ["de", "it"], - "de": { - "storeCode": "de", - "disabled": true, - "storeId": 3, - "name": "German Store", - "url": "/de", - "appendStoreCode": true, - "elasticsearch": { - "host": "/api/catalog", - "index": "vue_storefront_catalog_de" - }, - "tax": { - "sourcePriceIncludesTax": false, - "defaultCountry": "DE", - "defaultRegion": "", - "calculateServerSide": true - }, - "i18n": { - "fullCountryName": "Germany", - "fullLanguageName": "German", - "defaultLanguage": "DE", - "defaultCountry": "DE", - "defaultLocale": "de-DE", - "currencyCode": "EUR", - "currencySign": "EUR", - "dateFormat": "HH:mm D-M-YYYY" + "server": { + "host": "localhost", + "port": 3000, + "protocol": "http", + "api": "api", + "devServiceWorker": false, + "useHtmlMinifier": true, + "htmlMinifierOptions": { + "minifyJS": true, + "minifyCSS": true + }, + "useOutputCacheTagging": false, + "useOutputCache": false, + "outputCacheDefaultTtl": 86400, + "availableCacheTags": ["product", "category", "home", "checkout", "page-not-found", "compare", "my-account", "P", "C", "error", "attribute", "taxrule"], + "invalidateCacheKey": "aeSu7aip", + "invalidateCacheForwarding": false, + "invalidateCacheForwardUrl": "http://localhost:8080/invalidate?key=aeSu7aip&tag=", + "dynamicConfigReload": false, + "dynamicConfigContinueOnError": false, + "dynamicConfigExclude": ["ssr", "storeViews", "entities", "localForage", "shipping", "boost", "query"], + "dynamicConfigInclude": [], + "elasticCacheQuota": 4096, + "ssrDisabledFor": { + "extensions": [".png", ".gif", ".jpg", ".jpeg", ".woff", ".eot", ".woff2", ".ttf", ".svg", ".css", ".js", ".json", ".ico", ".tiff", ".tif", ".raw"] + }, + "trace": { + "enabled": false, + "config": {} + } + }, + "staticPages": { + "updateOnRequest": true, + "destPath": "static" + }, + "seo": { + "useUrlDispatcher": true, + "disableUrlRoutesPersistentCache": true, + "defaultTitle": "Vue Storefront" + }, + "console": { + "showErrorOnProduction" : false, + "verbosityLevel": "display-everything" + }, + "redis": { + "host": "localhost", + "port": 6379, + "db": 0 + }, + "graphql":{ + "host": "localhost", + "port": 8080 + }, + "api": { + "url": "http://localhost:8080" + }, + "elasticsearch": { + "httpAuth": "", + "host": "/api/catalog", + "index": "vue_storefront_catalog", + "min_score": 0.02, + "csrTimeout": 5000, + "ssrTimeout": 1000, + "queryMethod": "GET", + "disablePersistentQueriesCache": true, + "searchScoring": { + "attributes": { + "attribute_code": { + "scoreValues": { "attribute_value": { "weight": 1 } } } }, - "it": { - "storeCode": "it", - "disabled": true, - "storeId": 4, - "name": "Italian Store", - "url": "/it", - "appendStoreCode": true, - "elasticsearch": { - "host": "/api/catalog", - "index": "vue_storefront_catalog_it" - }, - "tax": { - "sourcePriceIncludesTax": false, - "defaultCountry": "IT", - "defaultRegion": "", - "calculateServerSide": true - }, - "i18n": { - "fullCountryName": "Italy", - "fullLanguageName": "Italian", - "defaultCountry": "IT", - "defaultLanguage": "IT", - "defaultLocale": "it-IT", - "currencyCode": "EUR", - "currencySign": "EUR", - "dateFormat": "HH:mm D-M-YYYY" - } - } - }, - "entities": { - "optimize": true, - "twoStageCaching": true, - "optimizeShoppingCart": true, - "category": { - "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, - "categoriesDynamicPrefetch": true + "fuzziness": 2, + "cutoff_frequency": 0.01, + "max_expansions": 3, + "minimum_should_match": "75%", + "prefix_length": 2, + "boost_mode": "multiply", + "score_mode": "multiply", + "max_boost": 100, + "function_min_score": 1 + }, + "searchableAttributes": { + "name": { + "boost": 4 }, - "attribute": { - "includeFields": [ "attribute_code", "id", "entity_type_id", "options", "default_value", "is_user_defined", "frontend_label", "attribute_id", "default_frontend_label", "is_visible_on_front", "is_visible", "is_comparable", "tier_prices", "frontend_input" ] + "sku": { + "boost": 2 }, - "productList": { - "sort": "updated_at:desc", - "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" ] + "category.name": { + "boost": 1 + } + } + }, + "ssr": { + "templates": { + "default": "dist/index.html", + "minimal": "dist/index.minimal.html", + "basic": "dist/index.basic.html", + "amp": "dist/index.amp.html" + }, + "lazyHydrateFor": ["category-next.products", "homepage.new_collection"], + "executeMixedinAsyncData": true, + "initialStateFilter": ["__DEMO_MODE__", "version", "storeView", "attribute.list_by_id"], + "useInitialStateFilter": true + }, + "queues": { + "maxNetworkTaskAttempts": 1, + "maxCartBypassAttempts": 1 + }, + "defaultStoreCode": "", + "storeViews": { + "multistore": false, + "commonCache": false, + "mapStoreUrlsFor": ["de", "it"], + "de": { + "storeCode": "de", + "storeId": 3, + "name": "German Store", + "url": "/de", + "appendStoreCode": true, + "elasticsearch": { + "host": "/api/catalog", + "index": "vue_storefront_catalog_de" }, - "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_path", "url_key", "status", "tier_prices"], - "excludeFields": [ "description", "sgn", "*.sgn", "msrp_display_actual_price_type", "*.msrp_display_actual_price_type", "required_options"] + "tax": { + "sourcePriceIncludesTax": false, + "defaultCountry": "DE", + "defaultRegion": "", + "calculateServerSide": true }, - "review": { - "excludeFields": ["review_entity", "review_status"] + "i18n": { + "fullCountryName": "Germany", + "fullLanguageName": "German", + "defaultLanguage": "DE", + "defaultCountry": "DE", + "defaultLocale": "de-DE", + "currencyCode": "EUR", + "currencySign": "EUR", + "dateFormat": "HH:mm D-M-YYYY" }, - "product": { - "excludeFields": [ "*.msrp_display_actual_price_type", "required_options", "updated_at", "created_at", "attribute_set_id", "options_container", "msrp_display_actual_price_type", "has_options", "stock.manage_stock", "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", "small_image", "sgn", "*.sgn"], - "includeFields": null, - "useDynamicAttributeLoader": true, - "standardSystemFields": [ - "description", - "configurable_options", - "tsk", - "custom_attributes", - "size_options", - "regular_price", - "final_price", - "price", - "color_options", - "id", - "links", - "gift_message_available", - "category_ids", - "sku", - "stock", - "image", - "thumbnail", - "visibility", - "type_id", - "tax_class_id", - "media_gallery", - "url_key", - "url_path", - "max_price", - "minimal_regular_price", - "special_price", - "minimal_price", - "name", - "configurable_children", - "max_regular_price", - "category", - "status", - "priceTax", - "priceInclTax", - "specialPriceTax", - "specialPriceInclTax", - "_score", - "slug", - "errors", - "info", - "erin_recommends", - "special_from_date", - "news_from_date", - "custom_design_from", - "originalPrice", - "originalPriceInclTax", - "parentSku", - "options", - "product_option", - "qty", - "is_configured" - ] + "seo": { + "defaultTitle": "Vue Storefront" } }, - "cart": { - "thumbnails": { - "width": 150, - "height": 150 - }, - "bypassCartLoaderForAuthorizedUsers": true, - "serverMergeByDefault": true, - "serverSyncCanRemoveLocalItems": false, - "serverSyncCanModifyLocalItems": false, - "synchronize": true, - "synchronize_totals": true, - "setCustomProductOptions": true, - "setConfigurableProductOptions": true, - "askBeforeRemoveProduct": true, - "displayItemDiscounts": true, - "minicartCountType": "quantities", - "create_endpoint": "/api/cart/create?token={{token}}", - "updateitem_endpoint": "/api/cart/update?token={{token}}&cartId={{cartId}}", - "deleteitem_endpoint": "/api/cart/delete?token={{token}}&cartId={{cartId}}", - "pull_endpoint": "/api/cart/pull?token={{token}}&cartId={{cartId}}", - "totals_endpoint": "/api/cart/totals?token={{token}}&cartId={{cartId}}", - "paymentmethods_endpoint": "/api/cart/payment-methods?token={{token}}&cartId={{cartId}}", - "shippingmethods_endpoint": "/api/cart/shipping-methods?token={{token}}&cartId={{cartId}}", - "shippinginfo_endpoint": "/api/cart/shipping-information?token={{token}}&cartId={{cartId}}", - "collecttotals_endpoint": "/api/cart/collect-totals?token={{token}}&cartId={{cartId}}", - "deletecoupon_endpoint": "/api/cart/delete-coupon?token={{token}}&cartId={{cartId}}", - "applycoupon_endpoint": "/api/cart/apply-coupon?token={{token}}&cartId={{cartId}}&coupon={{coupon}}" - }, - "products": { - "disablePersistentProductsCache": true, - "useMagentoUrlKeys": true, - "setFirstVarianAsDefaultInURL": false, - "configurableChildrenStockPrefetchStatic": false, - "configurableChildrenStockPrefetchDynamic": false, - "configurableChildrenStockPrefetchStaticPrefetchCount": 8, - "filterUnavailableVariants": false, - "listOutOfStockProducts": true, - "preventConfigurableChildrenDirectAccess": true, - "alwaysSyncPlatformPricesOver": false, - "clearPricesBeforePlatformSync": false, - "waitForPlatformSync": false, - "setupVariantByAttributeCode": true, - "endpoint": "/api/product", - "defaultFilters": ["color", "size", "price", "erin_recommends"], - "filterFieldMapping": { - "category.name": "category.name.keyword" - }, - "colorMappings": { - "Melange graphite": "#eeeeee" - }, - "defaultSortBy": { - "attribute": "updated_at", - "order": "desc" - }, - "sortByAttributes": { - "Latest": "updated_at:desc", - "Price: Low to high":"final_price", - "Price: High to low":"final_price:desc" + "it": { + "extend": "de", + "storeCode": "it", + "storeId": 4, + "name": "Italian Store", + "url": "/it", + "appendStoreCode": true, + "elasticsearch": { + "host": "/api/catalog", + "index": "vue_storefront_catalog_it" }, - "gallery": { - "mergeConfigurableChildren": true, - "imageAttributes": ["image","thumbnail","small_image"], - "width": 600, - "height": 744 + "tax": { + "defaultCountry": "IT" }, - "thumbnails": { - "width": 310, - "height": 300 + "i18n": { + "fullCountryName": "Italy", + "fullLanguageName": "Italian", + "defaultCountry": "IT", + "defaultLanguage": "IT", + "defaultLocale": "it-IT" }, - "filterAggregationSize": { - "default": 10, - "size": 10, - "color": 10 - }, - "priceFilters": { - "ranges": [ - { "from": 0, "to": 50 }, - { "from": 50, "to": 100 }, - { "from": 100, "to": 150 }, - { "from": 150 } - ] + "seo": { + "defaultTitle": "Vue Storefront" } - }, - "orders": { - "directBackendSync": true, - "endpoint": "/api/order", - "payment_methods_mapping": { - }, - "offline_orders": { - "automatic_transmission_enabled": false, - "notification" : { - "enabled": true, - "title" : "Order waiting!", - "message": "Click here to confirm the order that you made offline.", - "icon": "/assets/logo.png" - } + } + }, + "entities": { + "optimize": true, + "twoStageCaching": true, + "optimizeShoppingCart": true, + "optimizeShoppingCartOmitFields": ["configurable_children", "configurable_options", "media_gallery", "description", "category", "category_ids", "product_links", "stock", "description"], + "category": { + "includeFields": [ "id", "*.children_data.id", "*.id", "children_count", "sku", "name", "is_active", "parent_id", "level", "url_key", "url_path", "product_count", "path", "position"], + "excludeFields": [ "sgn" ], + "filterFields": {}, + "breadcrumbFilterFields": {}, + "categoriesRootCategorylId": 2, + "categoriesDynamicPrefetchLevel": 2, + "categoriesDynamicPrefetch": true, + "validSearchOptionsFromRouteParams": ["url-key", "slug", "id"] + }, + "attribute": { + "includeFields": [ "activity", "attribute_code", "id", "entity_type_id", "options", "default_value", "is_user_defined", "frontend_label", "attribute_id", "default_frontend_label", "is_visible_on_front", "is_visible", "is_comparable", "tier_prices", "frontend_input" ] + }, + "productList": { + "sort": "updated_at:desc", + "includeFields": [ "activity", "type_id", "*sku", "product_links", "tax_class_id", "special_price", "special_to_date", "special_from_date", "name", "price", "price_incl_tax", "original_price_incl_tax", "original_price", "special_price_incl_tax", "id", "image", "sale", "new", "url_path", "url_key", "status", "tier_prices", "configurable_children.sku", "configurable_children.price", "configurable_children.special_price", "configurable_children.price_incl_tax", "configurable_children.special_price_incl_tax", "configurable_children.original_price", "configurable_children.original_price_incl_tax", "*image","*small_image", "configurable_children.color", "configurable_children.size", "configurable_children.tier_prices", "final_price", "configurable_children.final_price"], + "excludeFields": [ "description", "configurable_options", "sgn", "*.sgn", "msrp_display_actual_price_type", "*.msrp_display_actual_price_type", "required_options" ] + }, + "productListWithChildren": { + "includeFields": [ "activity", "type_id", "sku", "name", "tax_class_id", "final_price", "special_price", "special_to_date", "special_from_date", "price", "price_incl_tax", "original_price_incl_tax", "original_price", "special_price_incl_tax", "id", "image", "sale", "new", "configurable_children.image", "configurable_children.sku", "configurable_children.price", "configurable_children.special_price", "configurable_children.price_incl_tax", "configurable_children.special_price_incl_tax", "configurable_children.original_price", "configurable_children.original_price_incl_tax", "configurable_children.color", "configurable_children.size", "configurable_children.id", "configurable_children.tier_prices", "product_links", "url_path", "url_key", "status", "tier_prices", "configurable_children.special_to_date", "configurable_children.special_from_date", "configurable_children.regular_price", "configurable_children.final_price"], + "excludeFields": [ "description", "sgn", "*.sgn", "msrp_display_actual_price_type", "*.msrp_display_actual_price_type", "required_options"] + }, + "review": { + "excludeFields": ["review_entity", "review_status"] + }, + "product": { + "excludeFields": [ "*.msrp_display_actual_price_type", "required_options", "updated_at", "created_at", "attribute_set_id", "options_container", "msrp_display_actual_price_type", "has_options", "stock.manage_stock", "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", "small_image", "sgn", "*.sgn"], + "includeFields": null, + "useDynamicAttributeLoader": true, + "standardSystemFields": [ + "description", + "configurable_options", + "tsk", + "custom_attributes", + "size_options", + "regular_price", + "final_price", + "final_price_incl_tax", + "final_price_tax", + "price", + "color_options", + "id", + "links", + "gift_message_available", + "category_ids", + "sku", + "stock", + "image", + "thumbnail", + "visibility", + "type_id", + "tax_class_id", + "media_gallery", + "url_key", + "url_path", + "max_price", + "minimal_regular_price", + "special_price", + "minimal_price", + "name", + "configurable_children", + "max_regular_price", + "category", + "status", + "price_tax", + "price_incl_tax", + "special_price_tax", + "special_price_incl_tax", + "_score", + "slug", + "errors", + "info", + "erin_recommends", + "special_from_date", + "news_from_date", + "custom_design_from", + "original_price", + "original_price_incl_tax", + "parentSku", + "options", + "product_option", + "qty", + "is_configured", + "priceInclTax", + "specialPriceInclTax", + "specialPriceTax", + "priceTax", + "priceInclTax" + ] + } + }, + "cart": { + "thumbnails": { + "width": 150, + "height": 150 + }, + "bypassCartLoaderForAuthorizedUsers": true, + "serverMergeByDefault": true, + "serverSyncCanRemoveLocalItems": false, + "serverSyncCanModifyLocalItems": false, + "synchronize": true, + "synchronize_totals": true, + "setCustomProductOptions": true, + "setConfigurableProductOptions": true, + "askBeforeRemoveProduct": true, + "displayItemDiscounts": true, + "productsAreReconfigurable": true, + "minicartCountType": "quantities", + "create_endpoint": "/api/cart/create?token={{token}}", + "updateitem_endpoint": "/api/cart/update?token={{token}}&cartId={{cartId}}", + "deleteitem_endpoint": "/api/cart/delete?token={{token}}&cartId={{cartId}}", + "pull_endpoint": "/api/cart/pull?token={{token}}&cartId={{cartId}}", + "totals_endpoint": "/api/cart/totals?token={{token}}&cartId={{cartId}}", + "paymentmethods_endpoint": "/api/cart/payment-methods?token={{token}}&cartId={{cartId}}", + "shippingmethods_endpoint": "/api/cart/shipping-methods?token={{token}}&cartId={{cartId}}", + "shippinginfo_endpoint": "/api/cart/shipping-information?token={{token}}&cartId={{cartId}}", + "collecttotals_endpoint": "/api/cart/collect-totals?token={{token}}&cartId={{cartId}}", + "deletecoupon_endpoint": "/api/cart/delete-coupon?token={{token}}&cartId={{cartId}}", + "applycoupon_endpoint": "/api/cart/apply-coupon?token={{token}}&cartId={{cartId}}&coupon={{coupon}}" + }, + "attributes": { + "disablePersistentAttributesCache": false + }, + "products": { + "disablePersistentProductsCache": true, + "useMagentoUrlKeys": true, + "setFirstVarianAsDefaultInURL": false, + "configurableChildrenStockPrefetchStatic": false, + "configurableChildrenStockPrefetchDynamic": true, + "configurableChildrenStockPrefetchStaticPrefetchCount": 8, + "filterUnavailableVariants": false, + "listOutOfStockProducts": true, + "preventConfigurableChildrenDirectAccess": true, + "alwaysSyncPlatformPricesOver": false, + "clearPricesBeforePlatformSync": false, + "waitForPlatformSync": false, + "setupVariantByAttributeCode": true, + "endpoint": "/api/product", + "defaultFilters": ["color", "size", "price", "erin_recommends"], + "systemFilterNames": ["sort"], + "maxFiltersQuerySize": 999, + "routerFiltersSource": "query", + "filterFieldMapping": { + "category.name": "category.name.keyword" + }, + "colorMappings": { + "Melange graphite": "#eeeeee" + }, + "defaultSortBy": { + "attribute": "updated_at", + "order": "desc" + }, + "sortByAttributes": { + "Latest": "updated_at:desc", + "Price: Low to high":"final_price", + "Price: High to low":"final_price:desc" + }, + "gallery": { + "mergeConfigurableChildren": true, + "imageAttributes": ["image","thumbnail","small_image"], + "width": 600, + "height": 744 + }, + "thumbnails": { + "width": 310, + "height": 300 + }, + "filterAggregationSize": { + "default": 10, + "size": 10, + "color": 10 + }, + "priceFilterKey": "final_price", + "priceFilters": { + "ranges": [ + { "from": 0, "to": 50 }, + { "from": 50, "to": 100 }, + { "from": 100, "to": 150 }, + { "from": 150 } + ] + } + }, + "orders": { + "directBackendSync": true, + "endpoint": "/api/order", + "payment_methods_mapping": { + }, + "offline_orders": { + "automatic_transmission_enabled": false, + "notification" : { + "enabled": true, + "title" : "Order waiting!", + "message": "Click here to confirm the order that you made offline.", + "icon": "/assets/logo.png" } - }, - "localForage": { - "defaultDrivers": { - "user": "LOCALSTORAGE", - "cmspage": "LOCALSTORAGE", - "cmsblock": "LOCALSTORAGE", - "carts": "LOCALSTORAGE", - "orders": "LOCALSTORAGE", - "wishlist": "LOCALSTORAGE", - "categories": "LOCALSTORAGE", - "attributes": "LOCALSTORAGE", - "elasticCache": "LOCALSTORAGE", - "claims": "LOCALSTORAGE", - "syncTasks": "LOCALSTORAGE", - "ordersHistory": "LOCALSTORAGE", - "checkoutFieldValues": "LOCALSTORAGE" + } + }, + "localForage": { + "defaultDrivers": { + "user": "LOCALSTORAGE", + "cmspage": "LOCALSTORAGE", + "cmsblock": "LOCALSTORAGE", + "carts": "LOCALSTORAGE", + "orders": "LOCALSTORAGE", + "wishlist": "LOCALSTORAGE", + "categories": "LOCALSTORAGE", + "attributes": "LOCALSTORAGE", + "elasticCache": "LOCALSTORAGE", + "claims": "LOCALSTORAGE", + "syncTasks": "LOCALSTORAGE", + "ordersHistory": "LOCALSTORAGE", + "checkout": "LOCALSTORAGE" + } + }, + "reviews": { + "create_endpoint": "/api/review/create" + }, + "users": { + "autoRefreshTokens": true, + "endpoint": "/api/user", + "history_endpoint": "/api/user/order-history?token={{token}}&pageSize={{pageSize}}¤tPage={{currentPage}}", + "resetPassword_endpoint": "/api/user/reset-password", + "changePassword_endpoint": "/api/user/change-password?token={{token}}", + "login_endpoint": "/api/user/login", + "create_endpoint": "/api/user/create", + "me_endpoint": "/api/user/me?token={{token}}", + "refresh_endpoint": "/api/user/refresh" + }, + "stock": { + "synchronize": true, + "allowOutOfStockInCart": true, + "endpoint": "/api/stock" + }, + "images": { + "useExactUrlsNoProxy": false, + "baseUrl": "https://demo.vuestorefront.io/img/", + "useSpecificImagePaths": false, + "paths": { + "product": "/catalog/product" + }, + "productPlaceholder": "/assets/placeholder.jpg" + }, + "install": { + "is_local_backend": true, + "backend_dir": "../vue-storefront-api" + }, + "demomode": false, + "tax": { + "defaultCountry": "US", + "defaultRegion": "", + "sourcePriceIncludesTax": false, + "calculateServerSide": true, + "userGroupId": null, + "useOnlyDefaultUserGroupId": false, + "deprecatedPriceFieldsSupport": true, + "finalPriceIncludesTax": false + }, + "shipping": { + "methods": [ + { + "method_title": "DPD Courier", + "method_code": "flatrate", + "carrier_code": "flatrate", + "amount": 4, + "price_incl_tax": 5, + "default": true, + "offline": true } - }, - "reviews": { - "create_endpoint": "/api/review/create" - }, - "users": { - "autoRefreshTokens": true, - "endpoint": "/api/user", - "history_endpoint": "/api/user/order-history?token={{token}}", - "resetPassword_endpoint": "/api/user/reset-password", - "changePassword_endpoint": "/api/user/change-password?token={{token}}", - "login_endpoint": "/api/user/login", - "create_endpoint": "/api/user/create", - "me_endpoint": "/api/user/me?token={{token}}", - "refresh_endpoint": "/api/user/refresh" - }, - "stock": { - "synchronize": true, - "allowOutOfStockInCart": true, - "endpoint": "/api/stock" - }, - "images": { - "useExactUrlsNoProxy": false, - "baseUrl": "https://demo.vuestorefront.io/img/", - "productPlaceholder": "/assets/placeholder.jpg" - }, - "install": { - "is_local_backend": true, - "backend_dir": "../vue-storefront-api" - }, - "demomode": false, - "tax": { - "defaultCountry": "US", - "defaultRegion": "", - "sourcePriceIncludesTax": false, - "calculateServerSide": true - }, - "shipping": { - "methods": [ + ] + }, + "syncTasks": { + "disablePersistentTaskQueue": true + }, + "i18n": { + "defaultCountry": "US", + "defaultLanguage": "EN", + "availableLocale": ["en-US","de-DE","fr-FR","es-ES","nl-NL", "ja-JP", "ru-RU", "it-IT", "pt-BR", "pl-PL", "cs-CZ"], + "defaultLocale": "en-US", + "currencyCode": "USD", + "currencySign": "$", + "priceFormat": "{sign}{amount}", + "dateFormat": "HH:mm D/M/YYYY", + "fullCountryName": "United States", + "fullLanguageName": "English", + "bundleAllStoreviewLanguages": true + }, + "expireHeaders": { + "default": "30d", + "application/json": "24h", + "image/png": "7d" + }, + "newsletter": { + "endpoint": "/api/ext/mailchimp-subscribe/subscribe" + }, + "mailer": { + "endpoint": { + "send": "/api/ext/mail-service/send-email", + "token": "/api/ext/mail-service/get-token" + }, + "contactAddress": "contributors@vuestorefront.io", + "sendConfirmation": true + }, + "theme": "@vue-storefront/theme-default", + "analytics": { + "id": false + }, + "googleTagManager": { + "id": false, + "debug": true, + "product_attributes": [ + "name", "id", "sku", { "priceInclTax": "price" }, { "qty": "quantity" } + ] + }, + "hotjar": { + "id": false + }, + "cms": { + "endpoint": "/api/ext/cms-data/cms{{type}}/{{cmsId}}", + "endpointIdentifier": "/api/ext/cms-data/cms{{type}}Identifier/{{cmsIdentifier}}/storeId/{{storeId}}" + }, + "cms_block": { + "max_count": 500 + }, + "cms_page": { + "max_count": 500 + }, + "usePriceTiers": false, + "useZeroPriceProduct": true, + "query": { + "inspirations": { + "filter": [ { - "method_title": "DPD Courier", - "method_code": "flatrate", - "carrier_code": "flatrate", - "amount": 4, - "price_incl_tax": 5, - "default": true, - "offline": true + "key": "category.name", + "value" : { "eq": "Performance Fabrics" } } ] }, - "syncTasks": { - "disablePersistentTaskQueue": true - }, - "i18n": { - "defaultCountry": "US", - "defaultLanguage": "EN", - "availableLocale": ["en-US","de-DE","fr-FR","es-ES","nl-NL", "ja-JP", "ru-RU", "it-IT", "pt-BR", "pl-PL", "cs-CZ"], - "defaultLocale": "en-US", - "currencyCode": "USD", - "currencySign": "$", - "currencySignPlacement": "preppend", - "dateFormat": "l LT", - "fullCountryName": "United States", - "fullLanguageName": "English", - "bundleAllStoreviewLanguages": true - }, - "expireHeaders": { - "default": "30d", - "application/json": "24h", - "image/png": "7d" - }, - "newsletter": { - "endpoint": "/api/ext/mailchimp-subscribe/subscribe" - }, - "mailer": { - "endpoint": { - "send": "/api/ext/mail-service/send-email", - "token": "/api/ext/mail-service/get-token" - }, - "contactAddress": "contributors@vuestorefront.io", - "sendConfirmation": true - }, - "theme": "@vue-storefront/theme-default", - "analytics": { - "id": false - }, - "googleTagManager": { - "id": false, - "debug": true - }, - "hotjar": { - "id": false - }, - "cms": { - "endpoint": "/api/ext/cms-data/cms{{type}}/{{cmsId}}", - "endpointIdentifier": "/api/ext/cms-data/cms{{type}}Identifier/{{cmsIdentifier}}/storeId/{{storeId}}" - }, - "cms_block": { - "max_count": 500 - }, - "cms_page": { - "max_count": 500 + "newProducts": { + "filter": [ + { + "key": "category.name", + "value" : { "eq": "Tees" } + } + ] }, - "usePriceTiers": false, - "useZeroPriceProduct": true, - "query": { - "inspirations": { - "filter": [ - { - "key": "category.name", - "value" : { "eq": "Performance Fabrics" } - } - ] - }, - "newProducts": { - "filter": [ - { - "key": "category.name", - "value" : { "eq": "Tees" } - } - ] - }, - "coolBags": { - "filter": [ - { - "key": "category.name", - "value" : { "eq": "Women" } - } - ] - }, - "bestSellers": { - "filter": [ - { - "key": "category.name", - "value" : { "eq": "Tees" } - } - ] - } + "bestSellers": { + "filter": [ + { + "key": "category.name", + "value" : { "eq": "Tees" } + } + ] } + } } - diff --git a/core/app.ts b/core/app.ts index 052a0d929..0cde365b6 100755 --- a/core/app.ts +++ b/core/app.ts @@ -2,8 +2,7 @@ import { Store } from 'vuex' import RootState from '@vue-storefront/core/types/RootState' import Vue from 'vue' import { isServer } from '@vue-storefront/core/helpers' - -// Plugins +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' import i18n from '@vue-storefront/i18n' import VueRouter from 'vue-router' import VueLazyload from 'vue-lazyload' @@ -11,59 +10,39 @@ import Vuelidate from 'vuelidate' import Meta from 'vue-meta' import { sync } from 'vuex-router-sync' import VueObserveVisibility from 'vue-observe-visibility' - -// Apollo GraphQL client import { getApolloProvider } from './scripts/resolvers/resolveGraphQL' - // TODO simplify by removing global mixins, plugins and filters - it can be done in normal 'vue' way import { registerTheme } from '@vue-storefront/core/lib/themes' import { themeEntry } from 'theme/index.js' import { registerModules } from '@vue-storefront/core/lib/module' import { prepareStoreView, currentStoreView } from '@vue-storefront/core/lib/multistore' - import * as coreMixins from '@vue-storefront/core/mixins' import * as coreFilters from '@vue-storefront/core/filters' import * as corePlugins from '@vue-storefront/core/compatibility/plugins' - import { once } from '@vue-storefront/core/helpers' import store from '@vue-storefront/core/store' - import { enabledModules } from './modules-entry' - -// Will be deprecated in 1.8 -import { registerExtensions } from '@vue-storefront/core/compatibility/lib/extensions' -import { registerExtensions as extensions } from 'src/extensions' 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 initialStateFactory from '@vue-storefront/core/helpers/initialStateFactory' +import { createRouter, createRouterProxy } from '@vue-storefront/core/helpers/router'; -function createRouter (): VueRouter { - return new VueRouter({ - mode: 'history', - base: __dirname, - scrollBehavior: (to, from, savedPosition) => { - if (to.hash) { - return { - selector: to.hash - } - } - if (savedPosition) { - return savedPosition - } else { - return {x: 0, y: 0} - } - } - }) -} +const stateFactory = initialStateFactory(store.state) let router: VueRouter = null +let routerProxy: VueRouter = null once('__VUE_EXTEND_RR__', () => { Vue.use(VueRouter) }) -const createApp = async (ssrContext, config, storeCode = null): Promise<{app: Vue, router: VueRouter, store: Store}> => { +const createApp = async (ssrContext, config, storeCode = null): Promise<{app: Vue, router: VueRouter, store: Store, initialState: RootState}> => { router = createRouter() + routerProxy = createRouterProxy(router) // sync router with vuex 'router' store - sync(store, router) + sync(store, routerProxy) // TODO: Don't mutate the state directly, use mutation instead store.state.version = process.env.APPVERSION store.state.config = config // @deprecated @@ -78,7 +57,7 @@ const createApp = async (ssrContext, config, storeCode = null): Promise<{app: Vu const storeView = await prepareStoreView(storeCode) // prepare the default storeView store.state.storeView = storeView - // to depreciate in near future + // @deprecated from 2.0 once('__VUE_EXTEND__', () => { Vue.use(Vuelidate) Vue.use(VueLazyload, {attempt: 2, preLoad: 1.5}) @@ -109,7 +88,7 @@ const createApp = async (ssrContext, config, storeCode = null): Promise<{app: Vu } let vueOptions = { - router, + router: routerProxy, store, i18n, render: h => h(themeEntry) @@ -125,13 +104,16 @@ const createApp = async (ssrContext, config, storeCode = null): Promise<{app: Vu ssrContext } + injectReferences(app, store, routerProxy, globalConfig) + registerClientModules() registerModules(enabledModules, appContext) - registerExtensions(extensions, app, router, store, config, ssrContext) - registerTheme(globalConfig.theme, app, router, store, globalConfig, ssrContext) + registerTheme(globalConfig.theme, app, routerProxy, store, globalConfig, ssrContext) - Vue.prototype.$bus.$emit('application-after-init', app) + coreHooksExecutors.afterAppInit() + // @deprecated from 2.0 + EventBus.$emit('application-after-init', app) - return { app, router, store } + return { app, router: routerProxy, store, initialState: stateFactory.createInitialState(store.state) } } -export { router, createApp } +export { routerProxy as router, createApp, router as baseRouter } diff --git a/core/build/cache-version.json b/core/build/cache-version.json deleted file mode 100644 index 0db3279e4..000000000 --- a/core/build/cache-version.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - -} diff --git a/core/build/module-build.config.js b/core/build/module-build.config.js new file mode 100644 index 000000000..a0d27ccf3 --- /dev/null +++ b/core/build/module-build.config.js @@ -0,0 +1,18 @@ +// Webpack config used to build VS modules +module.exports = { + mode: 'production', + entry: './src/index.ts', + output: { + libraryTarget: 'umd', + globalObject: 'typeof self !== \'undefined\' ? self : this' + }, + resolve: { + extensions: ['.ts', '.js', '.json'] + }, + module: { + rules: [ + { test: /\.ts$/, use: ['ts-loader'], exclude: /node_modules/ } + ] + }, + externals: ['@vue-storefront/core'] +} diff --git a/core/build/webpack.base.config.ts b/core/build/webpack.base.config.ts index a0f45176a..1cf00cb3e 100644 --- a/core/build/webpack.base.config.ts +++ b/core/build/webpack.base.config.ts @@ -1,3 +1,4 @@ +import { buildLocaleIgnorePattern } from './../i18n/helpers'; import path from 'path'; import config from 'config'; import fs from 'fs'; @@ -18,6 +19,7 @@ fs.writeFileSync( 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' @@ -26,11 +28,22 @@ 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([ - path.resolve(__dirname, '../../node_modules/@vue-storefront/i18n/resource/i18n/'), - path.resolve(__dirname, themeResources + '/i18n/') -], config) +translationPreprocessor(csvDirectories, config) const postcssConfig = { loader: 'postcss-loader', @@ -48,6 +61,7 @@ const isProd = process.env.NODE_ENV === 'production' // todo: usemultipage-webpack-plugin for multistore export default { plugins: [ + new webpack.ContextReplacementPlugin(/dayjs[/\\]locale$/, buildLocaleIgnorePattern()), new webpack.ProgressPlugin(), // new BundleAnalyzerPlugin({ // generateStatsFile: true @@ -117,7 +131,10 @@ export default { 'theme/resource': themeResources, // Backward compatible - '@vue-storefront/core/store/lib/multistore': path.resolve(__dirname, '../lib/multistore.ts') + '@vue-storefront/core/lib/store/multistore': path.resolve(__dirname, '../lib/multistore.ts'), + 'src/modules/order-history/components/UserOrders': path.resolve(__dirname, '../../core/modules/order/components/UserOrdersHistory'), + '@vue-storefront/core/modules/social-share/components/WebShare': path.resolve(__dirname, '../../src/themes/default/components/theme/WebShare.vue'), + '@vue-storefront/core/helpers/initCacheStorage': path.resolve(__dirname, '../lib/storage-manager.ts') } }, module: { diff --git a/core/build/webpack.prod.sw.config.ts b/core/build/webpack.prod.sw.config.ts index f21fb672a..64f90aa02 100644 --- a/core/build/webpack.prod.sw.config.ts +++ b/core/build/webpack.prod.sw.config.ts @@ -20,7 +20,9 @@ module.exports = merge(base, { filename: 'service-worker.js', staticFileGlobsIgnorePatterns: [/\.map$/], staticFileGlobs: [ - 'dist/**.*', + 'dist/**.*.js', + 'dist/**.*.json', + 'dist/**.*.css', 'assets/**.*', 'assets/ig/**.*', 'index.html', @@ -60,10 +62,12 @@ module.exports = merge(base, { { urlPattern: '/img/(.*)', handler: 'fastest' - }, { - urlPattern: '/api/catalog/*', + }, + { + urlPattern: /(http[s]?:\/\/)?(\/)?([^\/\s]+\/)?(api\/catalog\/)(.*)/g, // eslint-disable-line no-useless-escape handler: 'networkFirst' - }, { + }, + { urlPattern: '/api/*', handler: 'networkFirst' }, { diff --git a/core/client-entry.ts b/core/client-entry.ts index e02f4b207..c7135d0ad 100755 --- a/core/client-entry.ts +++ b/core/client-entry.ts @@ -1,31 +1,38 @@ import Vue from 'vue' import union from 'lodash-es/union' - import { createApp } from '@vue-storefront/core/app' import rootStore from '@vue-storefront/core/store' import { registerSyncTaskProcessor } from '@vue-storefront/core/lib/sync/task' import i18n from '@vue-storefront/i18n' +import omit from 'lodash-es/omit' import storeCodeFromRoute from '@vue-storefront/core/lib/storeCodeFromRoute' -import { prepareStoreView, currentStoreView, localizedRoute } from '@vue-storefront/core/lib/multistore' +import { currentStoreView, localizedRoute } from '@vue-storefront/core/lib/multistore' import { onNetworkStatusChange } from '@vue-storefront/core/modules/offline-order/helpers/onNetworkStatusChange' import '@vue-storefront/core/service-worker/registration' // register the service worker import { AsyncDataLoader } from './lib/async-data-loader' import { Logger } from '@vue-storefront/core/lib/logger' import globalConfig from 'config' +import { coreHooksExecutors } from './hooks' import { RouterManager } from './lib/router-manager'; declare var window: any const invokeClientEntry = async () => { const dynamicRuntimeConfig = window.__INITIAL_STATE__.config ? Object.assign(globalConfig, window.__INITIAL_STATE__.config) : globalConfig // Get storeCode from server (received either from cache header or env variable) - let storeCode = window.__INITIAL_STATE__.user.current_storecode + let storeCode = window.__INITIAL_STATE__.storeView.storeCode const { app, router, store } = await createApp(null, dynamicRuntimeConfig, storeCode) if (window.__INITIAL_STATE__) { - store.replaceState(Object.assign({}, store.state, window.__INITIAL_STATE__, { config: globalConfig })) + // skip fields that were set by createApp + const initialState = coreHooksExecutors.beforeHydrated( + omit(window.__INITIAL_STATE__, ['storeView', 'config', 'version']) + ) + store.replaceState(Object.assign({}, store.state, initialState, { config: globalConfig })) } await store.dispatch('url/registerDynamicRoutes') + RouterManager.flushRouteQueue() + function _commonErrorHandler (err, reject) { if (err.message.indexOf('query returned empty result') > 0) { rootStore.dispatch('notification/spawnNotification', { @@ -93,7 +100,7 @@ const invokeClientEntry = async () => { } } } - if (!matched.length) { + if (!matched.length || !matched[0]) { return next() } Promise.all(matched.map((c: any) => { // TODO: update me for mixins support diff --git a/core/compatibility/components/blocks/Category/Sidebar.js b/core/compatibility/components/blocks/Category/Sidebar.js index 71cc2699e..c422975fa 100644 --- a/core/compatibility/components/blocks/Category/Sidebar.js +++ b/core/compatibility/components/blocks/Category/Sidebar.js @@ -19,7 +19,7 @@ export default { return this.getActiveCategoryFilters }, availableFilters () { - return pickBy(this.filters, (filter) => { return (filter.length) }) + return pickBy(this.filters, (filter, filterType) => { return (filter.length && !this.$store.getters['category-next/getSystemFilterNames'].includes(filterType)) }) }, hasActiveFilters () { return Object.keys(this.activeFilters).length !== 0 diff --git a/core/compatibility/components/blocks/Microcart/Product.js b/core/compatibility/components/blocks/Microcart/Product.js index a4d239ef7..50326a8b5 100644 --- a/core/compatibility/components/blocks/Microcart/Product.js +++ b/core/compatibility/components/blocks/Microcart/Product.js @@ -13,7 +13,7 @@ export default { // deprecated, will be moved to theme or removed in the near future #1742 this.$bus.$on('cart-after-itemchanged', this.onProductChanged) this.$bus.$on('notification-after-itemremoved', this.onProductRemoved) - this.updateQuantity = debounce(this.updateQuantity, 5000) + this.updateQuantity = debounce(this.updateQuantity, 1000) }, beforeDestroy () { // deprecated, will be moved to theme or removed in the near future #1742 @@ -52,8 +52,5 @@ export default { this.removeFromCart(event.item) } } - }, - mixins: [ - MicrocartProduct - ] + } } diff --git a/core/compatibility/components/blocks/SearchPanel/SearchPanel.js b/core/compatibility/components/blocks/SearchPanel/SearchPanel.js index fff85d7e2..48614f244 100644 --- a/core/compatibility/components/blocks/SearchPanel/SearchPanel.js +++ b/core/compatibility/components/blocks/SearchPanel/SearchPanel.js @@ -3,11 +3,6 @@ import { Search } from '@vue-storefront/core/modules/catalog/components/Search' // Moved to search module export default { mixins: [Search], - data () { - return { - componentLoaded: false - } - }, computed: { showPanel () { return this.isOpen && this.componentLoaded diff --git a/core/compatibility/lib/extensions.ts b/core/compatibility/lib/extensions.ts deleted file mode 100644 index 6a2567dfd..000000000 --- a/core/compatibility/lib/extensions.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Logger } from '@vue-storefront/core/lib/logger' - -export function registerExtensions (extensions, app, router, store, config, ssrContext = null) { - for (let extEntryPoint of extensions) { - if (extEntryPoint !== null) { - if (extEntryPoint.default) extEntryPoint = extEntryPoint.default - let extDescriptor = extEntryPoint(app, router, store, config, ssrContext) // register module - if (extDescriptor != null) { - Logger.warn('Extension' + extDescriptor.EXTENSION_KEY + ' registered. Extensions are deprecated and will be removed from VS core. Use modules instead')() - app.$emit('application-after-registerExtensions', extDescriptor) - } - } - } -} diff --git a/core/compatibility/plugins/event-bus/index.js b/core/compatibility/plugins/event-bus/index.js index 959a24038..a81ebee44 100644 --- a/core/compatibility/plugins/event-bus/index.js +++ b/core/compatibility/plugins/event-bus/index.js @@ -61,7 +61,7 @@ if (!EventBus.$dataFilters) { const EventBusPlugin = { install (Vue) { - if (!Vue.prototype.$bus) { + if (!Vue.prototype.$bus) { /** Vue.prototype.$bus is now @deprecated please do use `EventBus` instead */ Object.defineProperties(Vue.prototype, { $bus: { get: function () { diff --git a/core/data-resolver/CartService.ts b/core/data-resolver/CartService.ts new file mode 100644 index 000000000..97b9b9333 --- /dev/null +++ b/core/data-resolver/CartService.ts @@ -0,0 +1,153 @@ +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' +import { TaskQueue } from '@vue-storefront/core/lib/sync' +import { processLocalizedURLAddress } from '@vue-storefront/core/helpers' +import config from 'config'; + +const setShippingInfo = async (addressInformation: any): Promise => + TaskQueue.execute({ + url: processLocalizedURLAddress(config.cart.shippinginfo_endpoint), + payload: { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + mode: 'cors', + body: JSON.stringify({ addressInformation }) + }, + silent: true + }); + +const getTotals = async (): Promise => + TaskQueue.execute({ + url: processLocalizedURLAddress(config.cart.totals_endpoint), + payload: { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + mode: 'cors' + }, + silent: true + }); + +const getCartToken = async (guestCart: boolean = false, forceClientState: boolean = false): Promise => { + const url = processLocalizedURLAddress(guestCart + ? config.cart.create_endpoint.replace('{{token}}', '') + : config.cart.create_endpoint) + + return TaskQueue.execute({ + url, + payload: { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + mode: 'cors' + }, + force_client_state: forceClientState, + silent: true + }); +} + +const updateItem = async (cartServerToken: string, cartItem: CartItem): Promise => + TaskQueue.execute({ + url: processLocalizedURLAddress(config.cart.updateitem_endpoint), + payload: { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + mode: 'cors', + body: JSON.stringify({ + cartItem: { + ...cartItem, + quoteId: cartItem.quoteId || cartServerToken + } + }) + } + }); + +const deleteItem = async (cartServerToken: string, cartItem: CartItem): Promise => + TaskQueue.execute({ + url: processLocalizedURLAddress(config.cart.deleteitem_endpoint), + payload: { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + mode: 'cors', + body: JSON.stringify({ + cartItem: { + ...cartItem, + quoteId: cartServerToken + } + }) + }, + silent: true + }); + +const getPaymentMethods = async (): Promise => + TaskQueue.execute({ + url: processLocalizedURLAddress(config.cart.paymentmethods_endpoint), + payload: { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + mode: 'cors' + }, + silent: true + }); + +const getShippingMethods = async (address: any): Promise => + TaskQueue.execute({ + url: processLocalizedURLAddress(config.cart.shippingmethods_endpoint), + payload: { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + mode: 'cors', + body: JSON.stringify({ + address + }) + }, + silent: true + }); + +const getItems = async (): Promise => + TaskQueue.execute({ + url: processLocalizedURLAddress(config.cart.pull_endpoint), + payload: { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + mode: 'cors' + }, + silent: true + }); + +const applyCoupon = async (couponCode: string): Promise => { + const url = processLocalizedURLAddress(config.cart.applycoupon_endpoint.replace('{{coupon}}', couponCode)) + + return TaskQueue.execute({ + url, + payload: { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + mode: 'cors' + }, + silent: false + }); +} + +const removeCoupon = async (): Promise => + TaskQueue.execute({ + url: processLocalizedURLAddress(config.cart.deletecoupon_endpoint), + payload: { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + mode: 'cors' + }, + silent: false + }); + +export const CartService: DataResolver.CartService = { + setShippingInfo, + getTotals, + getCartToken, + updateItem, + deleteItem, + getPaymentMethods, + getShippingMethods, + getItems, + applyCoupon, + removeCoupon +} diff --git a/core/data-resolver/CategoryService.ts b/core/data-resolver/CategoryService.ts new file mode 100644 index 000000000..8eed001af --- /dev/null +++ b/core/data-resolver/CategoryService.ts @@ -0,0 +1,52 @@ +import { quickSearchByQuery } from '@vue-storefront/core/lib/search'; +import SearchQuery from '@vue-storefront/core/lib/search/searchQuery'; +import config from 'config'; +import { DataResolver } from './types/DataResolver'; +import { Category } from 'core/modules/catalog-next/types/Category'; + +const getCategories = async ({ + parentId = null, + filters = {}, + 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 +}: DataResolver.CategorySearchOptions = {}): Promise => { + let searchQuery = new SearchQuery() + if (parentId) { + searchQuery = searchQuery.applyFilter({key: 'parent_id', value: {'eq': parentId}}) + } + if (level) { + searchQuery = searchQuery.applyFilter({key: 'level', value: {'eq': level}}) + } + + for (var [key, value] of Object.entries(filters)) { + if (value !== null) { + if (Array.isArray(value)) { + searchQuery = searchQuery.applyFilter({key: key, value: {'in': value}}) + } else if (typeof value === 'object') { + searchQuery = searchQuery.applyFilter({key: key, value: value}) + } else { + searchQuery = searchQuery.applyFilter({key: key, value: {'eq': value}}) + } + } + } + + if (onlyActive === true) { + searchQuery = searchQuery.applyFilter({key: 'is_active', value: {'eq': true}}) + } + + if (onlyNotEmpty === true) { + searchQuery = searchQuery.applyFilter({key: 'product_count', value: {'gt': 0}}) + } + const response = await quickSearchByQuery({ entityType: 'category', query: searchQuery, sort: sort, size: size, start: start, includeFields: includeFields, excludeFields: excludeFields }) + return response.items as Category[] +} + +export const CategoryService: DataResolver.CategoryService = { + getCategories +} diff --git a/core/data-resolver/NewsletterService.ts b/core/data-resolver/NewsletterService.ts new file mode 100644 index 000000000..4d1969dc9 --- /dev/null +++ b/core/data-resolver/NewsletterService.ts @@ -0,0 +1,43 @@ +import config from 'config'; +import { DataResolver } from './types/DataResolver'; +import { processURLAddress } from '@vue-storefront/core/helpers'; +import { TaskQueue } from '@vue-storefront/core/lib/sync' + +const isSubscribed = (email: string): Promise => + TaskQueue.execute({ + url: processURLAddress(config.newsletter.endpoint) + '?email=' + encodeURIComponent(email), + payload: { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + mode: 'cors' + }, + silent: true + }).then(({ result }) => result === 'subscribed') + +const subscribe = (email: string): Promise => + TaskQueue.execute({ + url: processURLAddress(config.newsletter.endpoint), + payload: { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + mode: 'cors', + body: JSON.stringify({ email }) + } + }).then(({ code }) => code === 200) + +const unsubscribe = (email: string): Promise => + TaskQueue.execute({ + url: processURLAddress(config.newsletter.endpoint), + payload: { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + mode: 'cors', + body: JSON.stringify({ email }) + } + }).then(({ code }) => code === 200) + +export const NewsletterService: DataResolver.NewsletterService = { + isSubscribed, + subscribe, + unsubscribe +} diff --git a/core/data-resolver/OrderService.ts b/core/data-resolver/OrderService.ts new file mode 100644 index 000000000..c77bc73f6 --- /dev/null +++ b/core/data-resolver/OrderService.ts @@ -0,0 +1,19 @@ +import config from 'config'; +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' + +const placeOrder = (order: Order): Promise => + TaskQueue.execute({ url: config.orders.endpoint, // sync the order + payload: { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + mode: 'cors', + body: JSON.stringify(order) + } + }) + +export const OrderService: DataResolver.OrderService = { + placeOrder +} diff --git a/core/data-resolver/ReviewsService.ts b/core/data-resolver/ReviewsService.ts new file mode 100644 index 000000000..4cfcaeb0a --- /dev/null +++ b/core/data-resolver/ReviewsService.ts @@ -0,0 +1,23 @@ +import { DataResolver } from './types/DataResolver'; +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'; + +const createReview = (review: Review): Promise => + TaskQueue.execute({ + url: processLocalizedURLAddress(config.reviews.create_endpoint), + payload: { + method: 'POST', + mode: 'cors', + headers: { + 'Accept': 'application/json, text/plain, */*', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ review }) + } + }).then(({ code }) => code === 200) + +export const ReviewsService: DataResolver.ReviewsService = { + createReview +} diff --git a/core/data-resolver/StockService.ts b/core/data-resolver/StockService.ts new file mode 100644 index 000000000..fa88961d0 --- /dev/null +++ b/core/data-resolver/StockService.ts @@ -0,0 +1,51 @@ +import config from 'config'; +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'; + +const queueCheck = (sku: string, actionName: string): Promise => + TaskQueue.queue({ + url: processURLAddress(`${config.stock.endpoint}/check?sku=${encodeURIComponent(sku)}`), + payload: { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + mode: 'cors' + }, + is_result_cacheable: true, + product_sku: sku, + callback_event: `store:${actionName}` + }) + +const check = (sku: string): Promise => + TaskQueue.execute({ + url: processURLAddress(`${config.stock.endpoint}/check?sku=${encodeURIComponent(sku)}`), + payload: { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + mode: 'cors' + }, + is_result_cacheable: true, + product_sku: sku + }) + +const list = (skuList: string[]): Promise => + TaskQueue.execute({ + url: processURLAddress( + `${config.stock.endpoint}/list?skus=${encodeURIComponent( + skuList.join(',') + )}` + ), + payload: { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + mode: 'cors' + }, + skus: skuList + }) + +export const StockService: DataResolver.StockService = { + check, + list, + queueCheck +} diff --git a/core/data-resolver/UserService.ts b/core/data-resolver/UserService.ts new file mode 100644 index 000000000..afbf9691a --- /dev/null +++ b/core/data-resolver/UserService.ts @@ -0,0 +1,108 @@ +import { DataResolver } from './types/DataResolver'; +import { UserProfile } from '@vue-storefront/core/modules/user/types/UserProfile' +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' + +const headers = { + 'Accept': 'application/json, text/plain, */*', + 'Content-Type': 'application/json' +} + +const resetPassword = async (email: string): Promise => + TaskQueue.execute({ + url: processLocalizedURLAddress(config.users.resetPassword_endpoint), + payload: { + method: 'POST', + mode: 'cors', + headers, + body: JSON.stringify({ email }) + } + }) + +const login = async (username: string, password: string): Promise => + TaskQueue.execute({ + url: processLocalizedURLAddress(config.users.login_endpoint), + payload: { + method: 'POST', + mode: 'cors', + headers, + body: JSON.stringify({ username, password }) + } + }) + +const register = async (customer: DataResolver.Customer, password: string): Promise => + TaskQueue.execute({ + url: processLocalizedURLAddress(config.users.create_endpoint), + payload: { + method: 'POST', + headers, + body: JSON.stringify({ customer, password }) + } + }) + +const updateProfile = async (userProfile: UserProfile, actionName: string): Promise => + TaskQueue.queue({ + url: processLocalizedURLAddress(config.users.me_endpoint), + payload: { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + mode: 'cors', + body: JSON.stringify(userProfile) + }, + callback_event: `store:${actionName}` + }) + +const getProfile = async () => + TaskQueue.execute({ + url: processLocalizedURLAddress(config.users.me_endpoint), + payload: { + method: 'GET', + mode: 'cors', + headers + } + }) + +const getOrdersHistory = async (pageSize = 20, currentPage = 1): Promise => + TaskQueue.execute({ + url: processLocalizedURLAddress( + config.users.history_endpoint.replace('{{pageSize}}', pageSize).replace('{{currentPage}}', currentPage) + ), + payload: { + method: 'GET', + mode: 'cors', + headers + } + }) + +const changePassword = async (passwordData: DataResolver.PasswordData): Promise => + TaskQueue.execute({ + url: processLocalizedURLAddress(config.users.changePassword_endpoint), + payload: { + method: 'POST', + mode: 'cors', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(passwordData) + } + }) + +const refreshToken = async (refreshToken: string): Promise => + fetch(processLocalizedURLAddress(config.users.refresh_endpoint), { + method: 'POST', + mode: 'cors', + headers, + body: JSON.stringify({ refreshToken }) + }).then(resp => resp.json()) + .then(resp => resp.result) + +export const UserService: DataResolver.UserService = { + resetPassword, + login, + register, + updateProfile, + getProfile, + getOrdersHistory, + changePassword, + refreshToken +} diff --git a/core/data-resolver/index.ts b/core/data-resolver/index.ts new file mode 100644 index 000000000..35504d297 --- /dev/null +++ b/core/data-resolver/index.ts @@ -0,0 +1,17 @@ +import { CategoryService } from './CategoryService' +import { UserService } from './UserService' +import { CartService } from './CartService' +import { OrderService } from './OrderService' +import { StockService } from './StockService' +import { ReviewsService } from './ReviewsService' +import { NewsletterService } from './NewsletterService' + +export { + CategoryService, + UserService, + CartService, + OrderService, + StockService, + ReviewsService, + NewsletterService +} diff --git a/core/data-resolver/types/DataResolver.d.ts b/core/data-resolver/types/DataResolver.d.ts new file mode 100644 index 000000000..40ff960c8 --- /dev/null +++ b/core/data-resolver/types/DataResolver.d.ts @@ -0,0 +1,83 @@ +import { Category } from 'core/modules/catalog-next/types/Category'; +import { UserProfile } from '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'; + +declare namespace DataResolver { + + interface CategorySearchOptions { + parentId?: number | string, + filters?: { [key: string]: string[] | string }, + level?: number, + onlyActive?: boolean, + onlyNotEmpty?: boolean, + size?: number, + start?: number, + sort?: string, + includeFields?: string[], + excludeFields?: string[], + reloadAll?: boolean + } + + interface Customer { + email: string, + firstname: string, + lastname: string, + addresses: string + } + + interface PasswordData { + currentPassword: string, + newPassword: string + } + + interface CategoryService { + getCategories: (searchRequest?: CategorySearchOptions) => Promise + } + + interface UserService { + resetPassword: (email: string) => Promise, + login: (username: string, password: string) => Promise, + register: (customer: Customer, pssword: string) => Promise, + updateProfile: (userProfile: UserProfile, actionName: string) => Promise, + getProfile: () => Promise, + getOrdersHistory: (pageSize?: number, currentPage?: number) => Promise, + changePassword: (passwordData: PasswordData) => Promise, + refreshToken: (refreshToken: string) => Promise + } + + interface CartService { + setShippingInfo: (methodsData: any /*: ShippingMethodsData */) => Promise, + getTotals: () => Promise, + getCartToken: (guestCart: boolean, forceClientState: boolean) => Promise, + updateItem: (cartServerToken: string, cartItem: CartItem) => Promise, + deleteItem: (cartServerToken: string, cartItem: CartItem) => Promise, + getPaymentMethods: () => Promise, + getShippingMethods: (address: any /*: ShippingMethodsData */) => Promise, + getItems: () => Promise, + applyCoupon: (couponCode: string) => Promise, + removeCoupon: () => Promise + } + + interface OrderService { + placeOrder: (order: Order) => Promise + } + + interface StockService { + check: (sku: string) => Promise, + queueCheck: (sku: string, actionName: string) => Promise, + list: (skuList: string[]) => Promise + } + + interface ReviewsService { + createReview: (review: Review) => Promise + } + + interface NewsletterService { + isSubscribed: (email: string) => Promise, + subscribe: (email: string) => Promise, + unsubscribe: (email: string) => Promise + } +} diff --git a/core/filters/date.js b/core/filters/date.js index 17ef6af25..fee5d4006 100644 --- a/core/filters/date.js +++ b/core/filters/date.js @@ -16,7 +16,7 @@ export function date (date, format) { const displayFormat = format || currentStoreView().i18n.dateFormat let storeLocale = currentStoreView().i18n.defaultLocale.toLocaleLowerCase() const separatorIndex = storeLocale.indexOf('-') - const languageCode = separatorIndex ? storeLocale.substr(0, separatorIndex) : storeLocale + const languageCode = (separatorIndex > -1) ? storeLocale.substr(0, separatorIndex) : storeLocale const isStoreLocale = dayjs().locale(storeLocale).locale() const isLanguageLocale = dayjs().locale(languageCode).locale() diff --git a/core/filters/price.js b/core/filters/price.js index a9e44c7cb..c69462fb4 100644 --- a/core/filters/price.js +++ b/core/filters/price.js @@ -1,4 +1,13 @@ -import { currentStoreView } from '@vue-storefront/core/lib/multistore' +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 }); +}; + +const applyCurrencySign = (formattedPrice, { currencySign, priceFormat }) => { + return priceFormat.replace('{sign}', currencySign).replace('{amount}', formattedPrice) +} /** * Converts number to price string @@ -6,30 +15,20 @@ import { currentStoreView } from '@vue-storefront/core/lib/multistore' */ export function price (value) { if (isNaN(value)) { - return value + return value; } - let formattedVal = Math.abs(parseFloat(value)).toFixed(2) - const storeView = currentStoreView() + const storeView = currentStoreView(); if (!storeView.i18n) { return value; } - const prependCurrency = (price) => { - return storeView.i18n.currencySign + price - } + const { defaultLocale, currencySign, priceFormat } = storeView.i18n - const appendCurrency = (price) => { - return price + storeView.i18n.currencySign - } - - if (storeView.i18n.currencySignPlacement === 'append') { - formattedVal = appendCurrency(formattedVal) - } else { - formattedVal = prependCurrency(formattedVal) - } + const formattedValue = formatValue(value, defaultLocale); + const valueWithSign = applyCurrencySign(formattedValue, { currencySign, priceFormat }) if (value >= 0) { - return formattedVal + return valueWithSign; } else { - return '-' + formattedVal + return '-' + valueWithSign; } } diff --git a/core/filters/product-messages/typed.ts b/core/filters/product-messages.ts similarity index 100% rename from core/filters/product-messages/typed.ts rename to core/filters/product-messages.ts diff --git a/core/filters/product-messages/index.js b/core/filters/product-messages/index.js deleted file mode 100644 index dda95cb7b..000000000 --- a/core/filters/product-messages/index.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Format message string for product validation messages object - */ -export function formatProductMessages (messages) { - const msgs = [] - for (const infoKey in messages) { - if (messages[infoKey]) { - msgs.push(messages[infoKey]) - } - } - return msgs.join(', ') -} diff --git a/core/helpers/exceptions.js b/core/helpers/exceptions.js deleted file mode 100644 index 181a2aa8a..000000000 --- a/core/helpers/exceptions.js +++ /dev/null @@ -1,14 +0,0 @@ -export class HttpError { - /** - * ValidationError to be used with multiple validation errors return from Ajv or other validators - * @param {Object} _validationMessages dictionary of validation errors - */ - constructor (message, code) { - this.message = message - this.code = code - this.name = 'ValidationError' - } - toString () { - return 'HttpError' + this.code + ': ' + this.message - } -} diff --git a/core/helpers/index.ts b/core/helpers/index.ts index 6ba0ae666..1addece6b 100644 --- a/core/helpers/index.ts +++ b/core/helpers/index.ts @@ -4,6 +4,22 @@ import { formatCategoryLink } from '@vue-storefront/core/modules/url/helpers' import Vue from 'vue' import config from 'config' 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'; + +export const processURLAddress = (url: string = '') => { + if (url.startsWith('/')) return `${config.api.url}${url}` + return url +} + +export const processLocalizedURLAddress = (url: string = '') => { + if (config.storeViews.multistore) { + return processURLAddress(adjustMultistoreApiUrl(url)) + } + + return processURLAddress(url) +} /** * Create slugify -> "create-slugify" permalink of text @@ -23,27 +39,36 @@ export function slugify (text) { } /** - * @param relativeUrl - * @param width - * @param height - * @returns {*} + * @param {string} relativeUrl + * @param {number} width + * @param {number} height + * @param {string} pathType + * @returns {string} */ -export function getThumbnailPath (relativeUrl, width, height) { +export function getThumbnailPath (relativeUrl: string, width: number = 0, height: number = 0, pathType: string = 'product'): string { if (config.images.useExactUrlsNoProxy) { - return relativeUrl // this is exact url mode + return coreHooksExecutors.afterProductThumbnailPathGenerate({ path: relativeUrl, sizeX: width, sizeY: height }).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) - let baseUrl = config.images.proxyUrl ? config.images.proxyUrl : config.images.baseUrl // 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 + // 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 + let baseUrl = processURLAddress(config.images.proxyUrl ? config.images.proxyUrl : config.images.baseUrl) if (baseUrl.indexOf('{{') >= 0) { baseUrl = baseUrl.replace('{{url}}', relativeUrl) - baseUrl = baseUrl.replace('{{width}}', width) - baseUrl = baseUrl.replace('{{height}}', height) + baseUrl = baseUrl.replace('{{width}}', width.toString()) + baseUrl = baseUrl.replace('{{height}}', height.toString()) resultUrl = baseUrl } else { - resultUrl = `${baseUrl}${parseInt(width)}/${parseInt(height)}/resize${relativeUrl}` + resultUrl = `${baseUrl}${width.toString()}/${height.toString()}/resize${relativeUrl}` } - return relativeUrl && relativeUrl.indexOf('no_selection') < 0 ? resultUrl : config.images.productPlaceholder || '' + const path = relativeUrl && relativeUrl.indexOf('no_selection') < 0 ? resultUrl : config.images.productPlaceholder || '' + + return coreHooksExecutors.afterProductThumbnailPathGenerate({ path, sizeX: width, sizeY: height }).path } } @@ -69,7 +94,7 @@ export function formatBreadCrumbRoutes (categoryPath) { */ export function productThumbnailPath (product, ignoreConfig = false) { let thumbnail = product.image - if ((product.type_id && product.type_id === 'configurable') && product.hasOwnProperty('configurable_children') && + if ((!thumbnail && product.type_id && product.type_id === 'configurable') && product.hasOwnProperty('configurable_children') && product.configurable_children.length && (ignoreConfig || !product.is_configured) && ('image' in product.configurable_children[0]) ) { @@ -124,24 +149,27 @@ export function baseFilterProductsQuery (parentCategory, filters = []) { // TODO return searchProductQuery } -export function buildFilterProductsQuery (currentCategory, chosenFilters, defaultFilters = null) { +export function buildFilterProductsQuery (currentCategory, chosenFilters = {}, defaultFilters = null) { let filterQr = baseFilterProductsQuery(currentCategory, defaultFilters == null ? config.products.defaultFilters : defaultFilters) // add choosedn filters for (let code of Object.keys(chosenFilters)) { const filter = chosenFilters[code] + const attributeCode = Array.isArray(filter) ? filter[0].attribute_code : filter.attribute_code - if (filter.attribute_code !== 'price') { - filterQr = filterQr.applyFilter({key: filter.attribute_code, value: {'eq': filter.id}, scope: 'catalog'}) + if (Array.isArray(filter) && attributeCode !== 'price') { + const values = filter.map(filter => filter.id) + filterQr = filterQr.applyFilter({key: attributeCode, value: {'in': values}, scope: 'catalog'}) + } else if (attributeCode !== 'price') { + filterQr = filterQr.applyFilter({key: attributeCode, value: {'eq': filter.id}, scope: 'catalog'}) } else { // multi should be possible filter here? const rangeqr = {} - if (filter.from) { - rangeqr['gte'] = filter.from - } - if (filter.to) { - rangeqr['lte'] = filter.to - } - filterQr = filterQr.applyFilter({key: filter.attribute_code, value: rangeqr, scope: 'catalog'}) + const filterValues = Array.isArray(filter) ? filter : [filter] + filterValues.forEach(singleFilter => { + if (singleFilter.from) rangeqr['gte'] = singleFilter.from + if (singleFilter.to) rangeqr['lte'] = singleFilter.to + }) + filterQr = filterQr.applyFilter({key: attributeCode, value: rangeqr, scope: 'catalog'}) } } @@ -173,11 +201,6 @@ export const routerHelper = Vue.observable({ !isServer && window.addEventListener('offline', () => { onlineHelper.isOnline = false }) !isServer && window.addEventListener('popstate', () => { routerHelper.popStateDetected = true }) -export const processURLAddress = (url: string = '') => { - if (url.startsWith('/')) return `${config.api.url}${url}` - return url -} - /* * serial executes Promises sequentially. * @param {funcs} An array of funcs that return promises. @@ -195,19 +218,37 @@ export const serial = async promises => { return results } -export const isBottomVisible = () => { - if (isServer) { - return false - } - const scrollY = window.scrollY - const visible = window.innerHeight - const pageHeight = document.documentElement.scrollHeight - const bottomOfPage = visible + scrollY >= pageHeight - - return bottomOfPage || pageHeight < visible -} - // helper to calcuate the hash of the shopping cart export const calcItemsHmac = (items, token) => { return sha3_224(JSON.stringify({ items, token: token })) } + +export function extendStore (moduleName: string | string[], module: any) { + const merge = function (object: any = {}, source: any) { + for (let key in source) { + if (Array.isArray(source[key])) { + object[key] = merge([], source[key]) + } else if (source[key] === null && !object[key]) { + object[key] = null + } else if (typeof source[key] === 'object' && Object.keys(source[key]).length > 0) { + object[key] = merge(object[key], source[key]) + } else if (typeof source[key] === 'object' && object === null) { + object = {} + object[key] = source[key] + } else { + object[key] = source[key] + } + } + return object + }; + moduleName = Array.isArray(moduleName) ? moduleName : [moduleName] + const originalModule: any = moduleName.reduce( + (state: any, moduleName: string) => state._children[moduleName], + (store as any)._modules.root + ) + const rawModule: any = merge({}, originalModule._rawModule) + const extendedModule: any = merge(rawModule, module) + + store.unregisterModule(moduleName) + store.registerModule(moduleName, extendedModule) +} diff --git a/core/helpers/initCacheStorage.ts b/core/helpers/initCacheStorage.ts deleted file mode 100644 index c0127bf89..000000000 --- a/core/helpers/initCacheStorage.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as localForage from 'localforage' -import UniversalStorage from '@vue-storefront/core/store/lib/storage' -import { currentStoreView } from '@vue-storefront/core/lib/multistore' -import config from 'config' - -/** Inits cache storage for given module. By default via local storage */ -export function initCacheStorage (key, localised = true) { - const storeView = currentStoreView() - const dbNamePrefix = storeView.storeCode ? storeView.storeCode + '-' : '' - const cacheDriver = config.localForage && config.localForage.defaultDrivers[key] - ? config.localForage.defaultDrivers[key] - : 'LOCALSTORAGE' - - return new UniversalStorage(localForage.createInstance({ - name: localised ? `${dbNamePrefix}shop` : 'shop', - storeName: key, - driver: localForage[cacheDriver] - })) -} diff --git a/core/helpers/initialStateFactory.ts b/core/helpers/initialStateFactory.ts new file mode 100644 index 000000000..b1e288402 --- /dev/null +++ b/core/helpers/initialStateFactory.ts @@ -0,0 +1,17 @@ +import cloneDeep from 'lodash-es/cloneDeep' +import pick from 'lodash-es/pick' + +const initialStateFactory = (defaultState) => { + // storing default values for the fields that will be set in createApp + const defaultFields = pick(defaultState, ['version', 'config', '__DEMO_MODE__', 'storeView']) + + const createInitialState = (currentState) => ({ + ...cloneDeep(currentState), + ...defaultFields, + storeView: { storeCode: currentState.storeView.storeCode } + }) + + return { createInitialState } +} + +export default initialStateFactory diff --git a/core/helpers/log.js b/core/helpers/internal.ts similarity index 72% rename from core/helpers/log.js rename to core/helpers/internal.ts index 1a725b9fd..71eb8ab62 100644 --- a/core/helpers/log.js +++ b/core/helpers/internal.ts @@ -1,5 +1,23 @@ import { isServer } from '@vue-storefront/core/helpers' +/** + * ValidationError to be used with multiple validation errors return from Ajv or other validators +*/ +export class HttpError { + private message: string + private code: string | number + private name: string + + public constructor (message, code) { + this.message = message + this.code = code + this.name = 'ValidationError' + } + public toString () { + return 'HttpError' + this.code + ': ' + this.message + } +} + /** * @param {string} level available options: 'no-console', 'only-errors', 'all' */ diff --git a/core/helpers/router.ts b/core/helpers/router.ts new file mode 100644 index 000000000..c625d7d4f --- /dev/null +++ b/core/helpers/router.ts @@ -0,0 +1,39 @@ +import VueRouter, { RouteConfig } from 'vue-router' +import { RouterManager } from '@vue-storefront/core/lib/router-manager'; + +export const createRouter = (): VueRouter => { + return new VueRouter({ + mode: 'history', + base: __dirname, + scrollBehavior: (to, from, savedPosition) => { + if (to.hash) { + return { + selector: to.hash + } + } + if (savedPosition) { + return savedPosition + } else if (to.path !== from.path) { // do not change scroll position when navigating on the same page (ex. change filters) + return {x: 0, y: 0} + } + } + }) +} + +export const createRouterProxy = (router: VueRouter): VueRouter => { + const ProxyConstructor = Proxy || require('proxy-polyfill/src/proxy') + + return new ProxyConstructor(router, { + get (target, propKey) { + const origMethod = target[propKey]; + + if (propKey === 'addRoutes') { + return function (routes: RouteConfig[], ...args): void { + return RouterManager.addRoutes(routes, ...args); + }; + } + + return origMethod; + } + }) +} diff --git a/core/helpers/test/unit/getThumbnailPath.spec.ts b/core/helpers/test/unit/getThumbnailPath.spec.ts new file mode 100644 index 000000000..dc85506d9 --- /dev/null +++ b/core/helpers/test/unit/getThumbnailPath.spec.ts @@ -0,0 +1,25 @@ +import { slugify } from '@vue-storefront/core/helpers' +import config from 'config' + +jest.clearAllMocks() +jest.mock('config', () => ({})) +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: {} +})) +jest.mock('@vue-storefront/core/store', () => ({})) +jest.mock('@vue-storefront/core/modules/url/helpers', () => ({})) +jest.mock('@vue-storefront/core/lib/multistore', () => ({})) + +describe('slugify', () => { + it('Check if all strings are replaced to the right chars and that text is lowercase in the return', () => { + expect(slugify('testing')).toBe('testing') + expect(slugify('testing--')).toBe('testing-') + expect(slugify('TESTING--&')).toBe('testing-and-') + expect(slugify('TESTING--& ')).toBe('testing-and-') + expect(slugify('TES TING--& ')).toBe('tes-ting-and-') + }) + + it('Check that an error is thrown when the parameter is not an string', () => { + expect(() => slugify(12)).toThrow('string.replace is not a function') + }) +}) diff --git a/core/helpers/test/unit/processURLAddress.spec.ts b/core/helpers/test/unit/processURLAddress.spec.ts new file mode 100644 index 000000000..d44e04126 --- /dev/null +++ b/core/helpers/test/unit/processURLAddress.spec.ts @@ -0,0 +1,21 @@ +import { processURLAddress } from '@vue-storefront/core/helpers' +import config from 'config' + +jest.clearAllMocks() +jest.mock('config', () => ({})) +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: {} +})) +jest.mock('@vue-storefront/core/store', () => ({})) +jest.mock('@vue-storefront/core/modules/url/helpers', () => ({})) +jest.mock('@vue-storefront/core/lib/multistore', () => ({})) + +describe('processURLAddress', () => { + it('Check that the url that comes back has the right value', () => { + config.api = { + url: 'api' + } + expect(processURLAddress('/testing')).toBe('api/testing') + expect(processURLAddress('testing')).toBe('testing') + }) +}) diff --git a/core/helpers/test/unit/slugify.spec.ts b/core/helpers/test/unit/slugify.spec.ts new file mode 100644 index 000000000..b9bdb582f --- /dev/null +++ b/core/helpers/test/unit/slugify.spec.ts @@ -0,0 +1,60 @@ +import { getThumbnailPath } from '@vue-storefront/core/helpers' +import config from 'config' + +jest.clearAllMocks() +jest.mock('config', () => ({})) +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: {} +})) +jest.mock('@vue-storefront/core/store', () => ({})) +jest.mock('@vue-storefront/core/modules/url/helpers', () => ({})) +jest.mock('@vue-storefront/core/lib/multistore', () => ({})) + +describe('getThumbnailPath', () => { + it('Get right value when useExactUrlsNoProxy is set', () => { + config.images = { + useExactUrlsNoProxy: true + } + expect(getThumbnailPath('testing')).toBe('testing') + }) + + it('Get right value when productPlaceholder is set', () => { + config.images = { + productPlaceholder: 'productPlaceholder' + } + expect(getThumbnailPath('no_selection')).toBe('productPlaceholder') + }) + + it('Get right value when useSpecificImagePaths is set', () => { + config.images = { + useSpecificImagePaths: true, + paths: { + product: '/catalog/product', + test: '/test' + } + } + expect(getThumbnailPath('/prod', 10, 10, 'test')).toBe('10/10/resize/test/prod') + expect(getThumbnailPath('/prod', 10, 10)).toBe('10/10/resize/catalog/product/prod') + }) + it('Get right value when useSpecificImagePaths and baseUrl are set', () => { + config.images = { + useSpecificImagePaths: true, + baseUrl: 'test/', + paths: { + product: '/catalog/product', + test: '/test' + } + } + expect(getThumbnailPath('/prod', 10, 10, 'test')).toBe('test/10/10/resize/test/prod') + expect(getThumbnailPath('/prod', 10, 10)).toBe('test/10/10/resize/catalog/product/prod') + }) + + it('Get right value when baseUrl is set', () => { + config.images = { + baseUrl: 'test/' + } + expect(getThumbnailPath('/test')).toBe('test/0/0/resize/test') + expect(getThumbnailPath('/test', 10, 20)).toBe('test/10/20/resize/test') + expect(getThumbnailPath('/test', 30, 20)).toBe('test/30/20/resize/test') + }) +}) diff --git a/core/helpers/validators.ts b/core/helpers/validators/index.ts similarity index 100% rename from core/helpers/validators.ts rename to core/helpers/validators/index.ts diff --git a/core/hooks.ts b/core/hooks.ts new file mode 100644 index 000000000..8e0c08368 --- /dev/null +++ b/core/hooks.ts @@ -0,0 +1,55 @@ +import { createListenerHook, createMutatorHook } from '@vue-storefront/core/lib/hooks' + +const { + hook: beforeStoreViewChangedHook, + executor: beforeStoreViewChangedExecutor +} = createMutatorHook() + +const { + hook: afterStoreViewChangedHook, + executor: afterStoreViewChangedExecutor +} = createListenerHook() + +const { + hook: afterAppInitHook, + executor: afterAppInitExecutor +} = createListenerHook() + +const { + hook: beforeHydratedHook, + executor: beforeHydratedExecutor +} = createMutatorHook() + +const { + hook: afterProductThumbnailPathGeneratedHook, + executor: afterProductThumbnailPathGeneratedExecutor +} = createMutatorHook<{ path: string, sizeX: number, sizeY: number }, { path: string }>() + +/** Only for internal usage in core */ +const coreHooksExecutors = { + afterAppInit: afterAppInitExecutor, + beforeStoreViewChanged: beforeStoreViewChangedExecutor, + afterStoreViewChanged: afterStoreViewChangedExecutor, + beforeHydrated: beforeHydratedExecutor, + afterProductThumbnailPathGenerate: afterProductThumbnailPathGeneratedExecutor +} + +const coreHooks = { + /** Hook is fired right after whole application is initialized. Modules are registered and theme setted up */ + afterAppInit: afterAppInitHook, + /** Hook is fired directly before changing current storeView (multistrore) + * @param storeView Inside this function you have access to order object that you can access and modify. It should return order object. + */ + beforeStoreViewChanged: beforeStoreViewChangedHook, + /** Hook is fired right after storeView (multistore) is changed + * @param storeView current storeView + */ + afterStoreViewChanged: afterStoreViewChangedHook, + beforeHydrated: beforeHydratedHook, + afterProductThumbnailPathGenerate: afterProductThumbnailPathGeneratedHook +} + +export { + coreHooks, + coreHooksExecutors +} diff --git a/core/i18n/helpers.ts b/core/i18n/helpers.ts new file mode 100644 index 000000000..d8e3d17ad --- /dev/null +++ b/core/i18n/helpers.ts @@ -0,0 +1,29 @@ +import config from 'config' + +export const currentBuildLocales = (): string[] => { + const defaultLocale = config.i18n.defaultLocale || 'en-US' + const multistoreLocales = config.storeViews.multistore + ? Object.values(config.storeViews) + .map((store: any) => store && typeof store === 'object' && store.i18n && store.i18n.defaultLocale) + .filter(Boolean) + : [] + const locales = multistoreLocales.includes(defaultLocale) + ? multistoreLocales + : [defaultLocale, ...multistoreLocales] + + return locales +} + +export const transformToShortLocales = (locales: string[]): string[] => locales.map(locale => { + const separatorIndex = locale.indexOf('-') + const shortLocale = separatorIndex ? locale.substr(0, separatorIndex) : locale + + return shortLocale +}) + +export const buildLocaleIgnorePattern = (): RegExp => { + const locales = transformToShortLocales(currentBuildLocales()) + const localesRegex = locales.map(locale => `${locale}$`).join('|') + + return new RegExp(localesRegex) +} diff --git a/core/i18n/index.ts b/core/i18n/index.ts index ec26ca962..d80a5bab3 100644 --- a/core/i18n/index.ts +++ b/core/i18n/index.ts @@ -28,13 +28,13 @@ function setI18nLanguage (lang: string): string { const loadDateLocales = async (lang: string = 'en'): Promise => { let localeCode = lang.toLocaleLowerCase() try { // try to load full locale name - await import(/* webpackChunkName: "dayjs-locales" */ `dayjs/locale/${localeCode}`) + await import(/* webpackChunkName: "dayjs-locales-[request]" */ `dayjs/locale/${localeCode}`) } catch (e) { // load simplified locale name, example: de-DE -> de const separatorIndex = localeCode.indexOf('-') if (separatorIndex) { localeCode = separatorIndex ? localeCode.substr(0, separatorIndex) : localeCode try { - await import(/* webpackChunkName: "dayjs-locales" */ `dayjs/locale/${localeCode}`) + await import(/* webpackChunkName: "dayjs-locales-[request]" */ `dayjs/locale/${localeCode}`) } catch (err) { Logger.debug('Unable to load translation from dayjs')() } @@ -66,6 +66,4 @@ export async function loadLanguageAsync (lang: string): Promise { return lang } -loadLanguageAsync(config.i18n.defaultLocale) - export default i18n diff --git a/core/i18n/package.json b/core/i18n/package.json index f7f30bf8d..075a23c42 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.11.0", "description": "Vue Storefront i18n", "license": "MIT", "main": "index.ts", diff --git a/core/i18n/resource/i18n/cs-CZ.csv b/core/i18n/resource/i18n/cs-CZ.csv index 9cd6a5de3..b0930afcf 100644 --- a/core/i18n/resource/i18n/cs-CZ.csv +++ b/core/i18n/resource/i18n/cs-CZ.csv @@ -1,67 +1,72 @@ -"Registering the account ...","Registrace účtu ..." -"No products synchronized for this category. Please come back while online!","V této kategorii nemáte žádné synchronizované produkty. Zkuste prosím znovu až budete online!" -"Shopping cart is empty. Please add some products before entering Checkout","Nákupní košík je prázdný. Prosím přidejte nějaké produkty než přistoupíte k nákupu" -"Out of stock!","Vyprodáno!" -" is out of the stock!"," není na skladě!" -"Some of the ordered products are not available!","Některé z objednaných produktů nejsou k dispozici!" -"Stock check in progress, please wait while available stock quantities are checked","Kontrola skladových zásob probíhá, prosím, počkejte, až se zkontroluje množství dostupných zásob" -"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.","Připojení k internetu není k dispozici. Stále si však můžete zboží objednat. Budeme vás informovat v případě, že některý z objednaných produktů není k dispozici, protože to v současnosti nelze zkontrolovat." -"No such configuration for the product. Please do choose another combination of attributes.","Neexistuje žádná taková konfigurace pro daný výrobek. Vyberte prosím jinou kombinaci vlastností." -"The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.","Systém si není jistý množstvím zásob (volatilní). Produkt byl přidán do košíku pro předběžnou rezervaci." -"This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!","Tato funkce zatím nebyla zavedena! Prosíme, podívejte se na https://github.com/DivanteLtd/vue-storefront/issues pro podrobný návod!" -"The product is out of stock and cannot be added to the cart!","Produkt není na skladě a nelze ho přidat do košíku!" -"Product has been added to the cart!","Produkt byl přidán do košíku!" -"Internal validation error. Please check if all required fields are filled in. Please contact us on {email}","Interní chyba ověření. Zkontrolujte, zda jsou vyplněna všechna povinná pole. Kontaktujte nás prosím na {email}" -"Product {productName} has been added to the compare!","Produkt {productName} byl přidán k porovnání!" -"Product {productName} has been removed from compare!","Produkt {productName} byl odstraněn z porovnávání!" -"Product {productName} has been added to wishlist!","Produkt {productName} byl přidán do seznamu přání!" -"Product {productName} has been removed from wishlit!","Produkt {productName} byl odstraněn ze seznamu přání!" +" is out of stock!"," není na skladě!" +"404 Page Not Found","404 Stránka nenalezena" "Account data has successfully been updated","Údaje účtu byly úspěšně aktualizovány" -"Newsletter preferences have successfully been updated","Preference zasílání novinek byly úspěšně aktualizovány" -"Reset password feature does not work while offline!","Funkce obnovení hesla nefunguje offline!" -"You are logged in!","Jste přihlášeni!" -"Please fix the validation errors","Opravte chyby ověření" -"Product price is unknown, product cannot be added to the cart!","Cena produktu není známa, produkt nelze přidat do košíku!" -"My Account","Můj účet" -"Type what you are looking for...","Zadejte, co hledáte..." -"Home Page","Domovská stránka" +"Add review","Přidat recenzi" +"Adding a review ...","Adding a review ..." +"Allow notification about the order","Povolit oznámení o objednávce" +"Are you sure you would like to remove this item from the shopping cart?","Opravdu chcete tuto položku odebrat z nákupního košíku?" "Checkout","Koupit" -"Subtotal incl. tax","Mezisoučet vč. daně" -"Grand total","Celkový součet" -"Field is required","Pole je povinné" -"Field is required.","Pole je povinné." -"You're logged out","Jste odhlášeni" "Compare Products","Porovnání produktů" -"404 Page Not Found","404 Stránka nenalezena" +"Compare products","Porovnejte produkty" +"Confirm your order","Potvrďte svou objednávku" +"Error refreshing user token. User is not authorized to access the resource","Chyba obnovení uživatelského kupónu. Uživatel nemá oprávnění k přístupu" "Error with response - bad content-type!","Chyba s odpovědí - špatný typ obsahu!" -"Unhandled error, wrong response format!","Neošetřená chyba, nesprávný formát odpovědi!" -"not authorized","neautorizováno" +"Extension developers would like to thank you for placing an order!","Vývojáři rozšíření by rádi poděkovali za zadání vaší objednávky!" +"Field is required","Pole je povinné" +"Field is required.","Pole je povinné." +"Grand total","Celkový součet" +"Home Page","Domovská stránka" +"In stock!","Skladem!" "Internal Application error while refreshing the tokens. Please clear the storage and refresh page.","Chyba interní aplikace při aktualizaci kupónů. Vymažte úložiště a stránku aktualizujte." -"Proceed to checkout","Přejděte k nákupu" +"Internal validation error. Please check if all required fields are filled in. Please contact us on {email}","Interní chyba ověření. Zkontrolujte, zda jsou vyplněna všechna povinná pole. Kontaktujte nás prosím na {email}" +"Must be greater than 0","Musí být větší než 0" +"My Account","Můj účet" +"Newsletter preferences have successfully been updated","Preference zasílání novinek byly úspěšně aktualizovány" +"No available product variants","Žádné dostupné varianty produktu" +"No products synchronized for this category. Please come back while online!","V této kategorii nemáte žádné synchronizované produkty. Zkuste prosím znovu až budete online!" +"No such configuration for the product. Please do choose another combination of attributes.","Neexistuje žádná taková konfigurace pro daný výrobek. Vyberte prosím jinou kombinaci vlastností." "OK","OK" +"Only {maxQuantity} products of this type are available!","Only {maxQuantity} products of this type are available!" +"Out of stock!","Vyprodáno!" "Out of the stock!","Není skladem!" -"In stock!","Skladem!" +"Payment Information","Informace o platbě" "Please configure product custom options and fix the validation errors","Přizpůsobte vlastní navolené možnosti produktu a opravte chyby ověření" -"Error refreshing user token. User is not authorized to access the resource","Chyba obnovení uživatelského kupónu. Uživatel nemá oprávnění k přístupu" -"Must be greater than 0","Musí být větší než 0" -"Please select the field which You like to sort by","Vyberte pole, dle kterého chcete seřadit" -"No available product variants","Žádné dostupné varianty produktu" -"email","email" -"password","heslo" -"Confirm your order","Potvrďte svou objednávku" "Please confirm order you placed when you was offline","Potvrďte objednávku, kterou jste zadali, když jste byli offline" -"Payment Information","Informace o platbě" -"You are to pay for this order upon delivery.","Tuto objednávku platíte při doručení." -"Allow notification about the order","Povolit oznámení o objednávce" -"Extension developers would like to thank you for placing an order!","Vývojáři rozšíření by rádi poděkovali za zadání vaší objednávky!" -"most you may purchase","nejvíce co si můžete koupit" -"have as many","mít tolik" -"Compare products","Porovnejte produkty" -"Reviews","Recenze" +"Please fix the validation errors","Opravte chyby ověření" +"Please select the field which You like to sort by","Vyberte pole, dle kterého chcete seřadit" +"Proceed to checkout","Přejděte k nákupu" +"Product has been added to the cart!","Produkt byl přidán do košíku!" +"Product price is unknown, product cannot be added to the cart!","Cena produktu není známa, produkt nelze přidat do košíku!" +"Product quantity has been updated!","Množství produktu bylo aktualizováno!" +"Product {productName} has been added to the compare!","Produkt {productName} byl přidán k porovnání!" +"Product {productName} has been added to wishlist!","Produkt {productName} byl přidán do seznamu přání!" +"Product {productName} has been removed from compare!","Produkt {productName} byl odstraněn z porovnávání!" +"Product {productName} has been removed from wishlist!","Produkt {productName} byl odstraněn ze seznamu přání!" +"Registering the account ...","Registrace účtu ..." +"Reset password feature does not work while offline!","Funkce obnovení hesla nefunguje offline!" "Review","Recenze" -"Add review","Přidat recenzi" +"Reviews","Recenze" +"Shopping cart is empty. Please add some products before entering Checkout","Nákupní košík je prázdný. Prosím přidejte nějaké produkty než přistoupíte k nákupu" +"Some of the ordered products are not available!","Některé z objednaných produktů nejsou k dispozici!" +"Stock check in progress, please wait while available stock quantities are checked","Kontrola skladových zásob probíhá, prosím, počkejte, až se zkontroluje množství dostupných zásob" +"Subtotal incl. tax","Mezisoučet vč. daně" "Summary","Shrnutí", +"The product is out of stock and cannot be added to the cart!","Produkt není na skladě a nelze ho přidat do košíku!" +"The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.","Systém si není jistý množstvím zásob (volatilní). Produkt byl přidán do košíku pro předběžnou rezervaci." +"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.","Připojení k internetu není k dispozici. Stále si však můžete zboží objednat. Budeme vás informovat v případě, že některý z objednaných produktů není k dispozici, protože to v současnosti nelze zkontrolovat." +"This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!","Tato funkce zatím nebyla zavedena! Prosíme, podívejte se na https://github.com/DivanteLtd/vue-storefront/issues pro podrobný návod!" +"Thumbnail","Thumbnail" +"Type what you are looking for...","Zadejte, co hledáte..." +"Unhandled error, wrong response format!","Neošetřená chyba, nesprávný formát odpovědi!" +"You are logged in!","Jste přihlášeni!" +"You are to pay for this order upon delivery.","Tuto objednávku platíte při doručení." +"You need to be logged in to see this page","Pro zobrazení této stránky musíte být přihlášeni" +"You submitted your review for moderation.","You submitted your review for moderation." +"You're logged out","Jste odhlášeni" +"email","email" +"have as many","mít tolik" "login","přihlásit se" +"most you may purchase","nejvíce co si můžete koupit" +"not authorized","neautorizováno" +"password","heslo" "to account","na účet" -"Are you sure you would like to remove this item from the shopping cart?","Opravdu chcete tuto položku odebrat z nákupního košíku?" -"You need to be logged in to see this page","Pro zobrazení této stránky musíte být přihlášeni" diff --git a/core/i18n/resource/i18n/de-DE.csv b/core/i18n/resource/i18n/de-DE.csv index 0572e0669..f9998fe98 100644 --- a/core/i18n/resource/i18n/de-DE.csv +++ b/core/i18n/resource/i18n/de-DE.csv @@ -1,77 +1,78 @@ -"Registering the account ...","Registrieren des Kontos ..." -"No products synchronized for this category. Please come back while online!","Es sind keine Produkte für diese Kategorie synchronisiert. Bitte versuche es erneut, wenn du online bist!" -"Shopping cart is empty. Please add some products before entering Checkout","Dein Warenkorb ist leer. Bitte füge mindestens ein Produkt hinzu bevor du zur Kasse gehst" -"Out of stock!","Nicht auf Lager!" -" is out of the stock!"," ist nicht auf Lager!" -"Some of the ordered products are not available!","Einige der bestellten Produkte sind nicht auf Lager!" -"Please wait ...","Bitte warten ..." -"Stock check in progress, please wait while available stock quantities are checked","Bestandskontrolle läuft. Bitte warte einen Moment bis die verfügbare Bestandsmenge geprüft worden ist" -"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.","Es besteht aktuell keine Verbindung zum Internet. Du kannst deine Bestellung dennoch aufgeben. Falls eines der bestellten Produkte bei Wiederaufbau der Verbindung nicht mehr verfügbar sein sollte, werden wir dich umgehend benachrichtigen." -"No such configuration for the product. Please do choose another combination of attributes.","Diese Konfiguration ist für dieses Produkt nicht möglich. Bitte wähle eine andere Kombination von Eigenschaften." -"The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.","Das System konnte den genauen Lagerbestand nicht ermitteln, da dieser sehr volatil ist. Das Produkt wurde zur Vorreservierung in den Warenkorb gelegt." -"This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!","Diese Funktion wurde noch nicht implementiert. Für weitere Details schau dir bitte auf https://github.com/DivanteLtd/vue-storefront/issues unsere Roadmap an!" -"The product is out of stock and cannot be added to the cart!","Das Produkt ist nicht auf Lager und kann daher nicht zum Warenkorb hinzugefügt werden!" -"Product has been added to the cart!","Produkt wurde zum Warenkorb hinzugefügt!" -"Product quantity has been updated!","Die Anzahl wurde upgedated!" -"Internal validation error. Please check if all required fields are filled in. Please contact us on {email}","Interner Validierungsfehler. Bitte überprüfe, ob alle erforderlichen Felder ausgefüllt sind. Bei Problemen kontaktiere uns bitte über {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.","Die angegebene Adresse ist nicht gültig. Bitte überprüfe, ob alle notwenigen Felder ausgefüllt sind und kontaktiere uns per {email} um den Fehler für die Zukunft zu beheben. Deine Bestellung wurde abgebrochen." -"Product {productName} has been added to the compare!","Das Produkt {productName} wurde zur Vergleichsliste hinzugefügt!" -"Product {productName} has been removed from compare!","Das Produkt {productName} wurde von der Vergleichsliste entfernt!" -"Product {productName} has been added to wishlist!","Das Produkt {productName} wurde der Wunschliste hinzugefügt!" -"Product {productName} has been removed from wishlit!","Das Produkt {productName} wurde von der Wunschliste entfernt!" +" is out of stock!"," ist nicht auf Lager!" +"404 Page Not Found","404 Seite nicht gefunden" "Account data has successfully been updated","Kontodaten wurden erfolgreich aktualisiert" -"Newsletter preferences have successfully been updated","Die Newsletter-Einstellungen wurden erfolgreich aktualisiert" -"Reset password feature does not work while offline!","Die Funktion zum Zurücksetzen des Passworts funktioniert nicht im Offline-Modus!" -"You are logged in!","Du wurdest eingeloggt!" -"Please fix the validation errors","Bitte beheb die Validierungsfehler" -"Product price is unknown, product cannot be added to the cart!","Der Produktpreis ist unbekannt, daher kann dieses Produkt nicht zum Warenkorb hinzugefügt werden!" -"My Account","Mein Konto" -"Type what you are looking for...","Geben Sie ein, wonach Sie suchen..." -"Home Page","Startseite" +"Add review","Bewertung hinzufügen" +"Adding a review ...","Adding a review ..." +"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.","Die angegebene Adresse ist nicht gültig. Bitte überprüfen Sie, ob alle notwenigen Felder ausgefüllt sind und kontaktieren Sie uns per {email} um den Fehler für die Zukunft zu beheben. Ihre Bestellung wurde abgebrochen." +"Allow notification about the order","Erlauben Sie Benachrichtigungen über die Bestellung" +"Are you sure you would like to remove this item from the shopping cart?","Sind Sie sicher, dass Sie diesen Artikel aus Ihrem Warenkorb entfernen wollen?" "Checkout","Kasse" -"Subtotal incl. tax","Zwischensumme inkl. MwSt." -"Grand total","Gesamtsumme" -"Field is required","Dies ist ein Pflichtfeld" -"Field is required.","Dies ist ein Pflichtfeld." -"You're logged out","Sie wurden ausgeloggt" "Compare Products","Produkte vergleichen" -"404 Page Not Found","404 Seite nicht gefunden" +"Compare products","Produkte vergleichen" +"Confirm your order","Bestätigen Sie ihre Bestellung" +"Error refreshing user token. User is not authorized to access the resource","Fehler bei der Erneuerung des Benutzer-Tokens. Der Benutzer ist nicht authorisiert auf die Resource zuzugreifen" "Error with response - bad content-type!","Fehler in der Antwort vom Server - Falscher Inhaltstyp!" -"Unhandled error, wrong response format!","Unbehandelter Fehler. Die Antwort vom Server ist falsch formatiert!" -"not authorized","Nicht authorisiert" +"Error: Error while adding products","Error: Fehler beim hinzufügen der Produkte" +"Extension developers would like to thank you for placing an order!","Extension-Entwickler würden sich bei Ihnen gerne dafür bedanken, dass Sie eine Bestellung getätigt haben!" +"Field is required","Dies ist ein Pflichtfeld" +"Field is required.","Dies ist ein Pflichtfeld." +"Grand total","Gesamtsumme" +"Home Page","Startseite" +"In stock!","Auf Lager!" "Internal Application error while refreshing the tokens. Please clear the storage and refresh page.","Ein interner Anwendungsfehler ist, während der Erneuerung des Tokens, aufgetreten. Bitte leeren Sie den Speicher und laden Sie die Seite neu." -"Proceed to checkout","Weiter zur Kasse" +"Internal validation error. Please check if all required fields are filled in. Please contact us on {email}","Interner Validierungsfehler. Bitte überprüfen Sie, ob alle erforderlichen Felder ausgefüllt sind. Bei Problemen kontaktieren Sie uns bitte über {email}" +"Must be greater than 0","Muss größer als 0 sein" +"My Account","Mein Konto" +"Newsletter preferences have successfully been updated","Die Newsletter-Einstellungen wurden erfolgreich aktualisiert" +"No available product variants","Keine Produkte verfügbar" +"No products synchronized for this category. Please come back while online!","Es sind keine Produkte für diese Kategorie synchronisiert. Bitte versuchen Sie es erneut, wenn Sie online sind!" +"No such configuration for the product. Please do choose another combination of attributes.","Diese Konfiguration ist für dieses Produkt nicht möglich. Bitte wählen Sie eine andere Kombination von Eigenschaften." "OK","OK" +"Only {maxQuantity} products of this type are available!","Only {maxQuantity} products of this type are available!" +"Out of stock!","Nicht auf Lager!" "Out of the stock!","Nicht mehr auf Lager!" -"In stock!","Auf Lager!" +"Payment Information","Bezahlinformationen" +"Please configure product bundle options and fix the validation errors","Bitte konfigurieren Sie die Produktbündel-Optionen und beheben Sie die Validierungsfehler" "Please configure product custom options and fix the validation errors","Bitte konfigurieren Sie die angepassten Produktoptionen und beheben Sie die Validierungsfehler" -"Error refreshing user token. User is not authorized to access the resource","Fehler bei der Erneuerung des Benutzer-Tokens. Der Benutzer ist nicht authorisiert auf die Resource zuzugreifen" -"Must be greater than 0","Muss größer als 0 sein" -"Please select the field which You like to sort by","Bitte wählen Sie das Feld aus, nach dem Sie sortieren möchten" -"No available product variants","Keine Produkte verfügbar" -"email","Email" -"password","Passwort" -"Confirm your order","Bestätigen Sie ihre Bestellung" "Please confirm order you placed when you was offline","Bitte bestätigen Sie ihre Bestellung, welche Sie getätigt haben während Sie offline waren" -"Payment Information","Bezahlinformationen" -"You are to pay for this order upon delivery.","Sie müssen bei Lieferung bezahlen." -"Allow notification about the order","Erlauben Sie Benachrichtigungen über die Bestellung" -"Extension developers would like to thank you for placing an order!","Extension-Entwickler würden sich bei ihnen gerne dafür bedanken, dass Sie eine Bestellung getätigt haben!" -"most you may purchase"," maximum das Sie kaufen können" -"have as many","hat so viele" -"Compare products","Produkte vergleichen" -"Reviews","Bewertungen" +"Please fix the validation errors","Bitte beheben Sie die Validierungsfehler" +"Please select the field which You like to sort by","Bitte wählen Sie das Feld aus, nach dem Sie sortieren möchten" +"Proceed to checkout","Weiter zur Kasse" +"Processing order...","Bestellung wird verarbeitet..." +"Product has been added to the cart!","Produkt wurde zum Warenkorb hinzugefügt!" +"Product price is unknown, product cannot be added to the cart!","Der Produktpreis ist unbekannt, daher kann dieses Produkt nicht zum Warenkorb hinzugefügt werden!" +"Product quantity has been updated!","Produktmenge wurde aktualisiert!" +"Product {productName} has been added to the compare!","Das Produkt {productName} wurde zur Vergleichsliste hinzugefügt!" +"Product {productName} has been added to wishlist!","Das Produkt {productName} wurde der Wunschliste hinzugefügt!" +"Product {productName} has been removed from compare!","Das Produkt {productName} wurde von der Vergleichsliste entfernt!" +"Product {productName} has been removed from wishlist!","Das Produkt {productName} wurde von der Wunschliste entfernt!" +"Quantity must be above 0","Die Menge muss größer als 0 sein" +"Registering the account ...","Registrieren des Kontos ..." +"Reset password feature does not work while offline!","Die Funktion zum Zurücksetzen des Passworts funktioniert nicht im Offline-Modus!" "Review","Bewertung" -"Add review","Bewertung hinzufügen" +"Reviews","Bewertungen" +"Shopping cart is empty. Please add some products before entering Checkout","Ihr Warenkorb ist leer. Bitte fügen Sie mindestens ein Produkt hinzu bevor Sie zur Kasse gehen" +"Some of the ordered products are not available!","Einige der bestellten Produkte sind nicht auf Lager!" +"Stock check in progress, please wait while available stock quantities are checked","Bestandskontrolle läuft. Bitte warten Sie einen Moment bis die verfügbare Bestandsmenge geprüft worden ist" +"Subtotal incl. tax","Zwischensumme inkl. MwSt." "Summary","Zusammenfassung" -"login","Login" -"to account","zum Account" -"Are you sure you would like to remove this item from the shopping cart?","Sind Sie sicher, dass Sie diesen Artikel aus Ihrem Warenkorb entfernen wollen?" +"The product is out of stock and cannot be added to the cart!","Das Produkt ist nicht auf Lager und kann daher nicht zum Warenkorb hinzugefügt werden!" "The product, category or CMS page is not available in Offline mode. Redirecting to Home.","Das Produkt, die Kategorie oder die CMS Seite ist nicht verfügbar im Offline-Modus. Weiterleitung zur Startseite." -"Please configure product bundle options and fix the validation errors","Bitte konfigurieren Sie die Produktbündel-Optionen und beheben Sie die Validierungsfehler" -"Processing order...","Bestellung wird verarbeitet..." +"The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.","Das System konnte den genauen Lagerbestand nicht ermitteln, da dieser sehr volatil ist. Das Produkt wurde zur Vorreservierung in den Warenkorb gelegt." +"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.","Es besteht aktuell keine Verbindung zum Internet. Sie können ihre Bestellung dennoch aufgeben. Falls eines der bestellten Produkte bei Wiederaufbau der Verbindung nicht mehr verfügbar sein sollte, werden wir Sie umgehend benachrichtigen." +"This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!","Diese Funktion wurde noch nicht implementiert. Für weitere Details schauen Sie bitte auf https://github.com/DivanteLtd/vue-storefront/issues unsere Roadmap an!" +"Thumbnail","Thumbnail" +"Type what you are looking for...","Geben Sie ein, wonach Sie suchen..." +"Unhandled error, wrong response format!","Unbehandelter Fehler. Die Antwort vom Server ist falsch formatiert!" +"You are logged in!","Du wurdest eingeloggt!" +"You are to pay for this order upon delivery.","Sie müssen bei Lieferung bezahlen." "You need to be logged in to see this page","Sie müssen angemeldet sein, um diese Seite anzuzeigen" -"Quantity must be above 0","Die Menge muss größer als 0 sein" -"Error: Error while adding products","Error: Fehler beim hinzufügen der Produkte" -"Unexpected authorization error. Check your Network conection.","Unerwarteter Fehler bei der Authentifizierung. Bitte überprüfen Sie Ihre Internetverbindung." -"Columns","Spalten" +"You submitted your review for moderation.","You submitted your review for moderation." +"You're logged out","Sie wurden ausgeloggt" +"email","Email" +"have as many","hat so viele" +"login","Login" +"most you may purchase"," maximum das Sie kaufen können" +"not authorized","Nicht authorisiert" +"password","Passwort" +"to account","zum Account" diff --git a/core/i18n/resource/i18n/en-US.csv b/core/i18n/resource/i18n/en-US.csv index 9213badba..ebee75241 100644 --- a/core/i18n/resource/i18n/en-US.csv +++ b/core/i18n/resource/i18n/en-US.csv @@ -1,77 +1,90 @@ -"Registering the account ...","Registering the account ..." +" is out of stock!"," is out of stock!" +"404 Page Not Found","404 Page Not Found" +"Account data has successfully been updated","Account data has successfully been updated" +"Add review","Add review" +"Adding a review ...","Adding a review ..." +"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.","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." +"Allow notification about the order","Allow notification about the order" +"Are you sure you would like to remove this item from the shopping cart?","Are you sure you would like to remove this item from the shopping cart?" +"Checkout","Checkout" +"Columns","Columns" +"Compare Products","Compare Products" +"Compare products","Compare products" +"Confirm your order","Confirm your order" +"Error refreshing user token. User is not authorized to access the resource","Error refreshing user token. User is not authorized to access the resource" +"Error with response - bad content-type!","Error with response - bad content-type!" +"Error: Error while adding products","Error: Error while adding products" +"Extension developers would like to thank you for placing an order!","Extension developers would like to thank you for placing an order!" +"Field is required","Field is required" +"Field is required.","Field is required." +"Grand total","Grand total" +"Home Page","Home Page" +"In stock!","In stock!" +"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." +"Internal validation error. Please check if all required fields are filled in. Please contact us on {email}","Internal validation error. Please check if all required fields are filled in. Please contact us on {email}" +"Must be greater than 0","Must be greater than 0" +"My Account","My Account" +"Newsletter preferences have successfully been updated","Newsletter preferences have successfully been updated" +"No available product variants","No available product variants" "No products synchronized for this category. Please come back while online!","No products synchronized for this category. Please come back while online!" -"Shopping cart is empty. Please add some products before entering Checkout","Shopping cart is empty. Please add some products before entering Checkout" +"No such configuration for the product. Please do choose another combination of attributes.","No such configuration for the product. Please do choose another combination of attributes." +"OK","OK" +"Only {maxQuantity} products of this type are available!","Only {maxQuantity} products of this type are available!" +"Or if you will stay on "Order confirmation" page, the order will be placed automatically without confirmation, once the internet connection will be back.","Or if you will stay on "Order confirmation" page, the order will be placed automatically without confirmation, once the internet connection will be back." "Out of stock!","Out of stock!" -" is out of the stock!"," is out of the stock!" -"Some of the ordered products are not available!","Some of the ordered products are not available!" +"Out of the stock!","Out of the stock!" +"Payment Information","Payment Information" +"Please configure product bundle options and fix the validation errors","Please configure product bundle options and fix the validation errors" +"Please configure product custom options and fix the validation errors","Please configure product custom options and fix the validation errors" +"Please confirm order you placed when you was offline","Please confirm order you placed when you was offline" +"Please fix the validation errors","Please fix the validation errors" +"Please select the field which You like to sort by","Please select the field which You like to sort by" "Please wait ...","Please wait ..." -"Stock check in progress, please wait while available stock quantities are checked","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.","There is no Internet connection. You can still place your order. We will notify you if any of ordered products is not avaiable because we cannot check it right now." -"No such configuration for the product. Please do choose another combination of attributes.","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.","The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation." -"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!","The product is out of stock and cannot be added to the cart!" +"Proceed to checkout","Proceed to checkout" +"Processing order...","Processing order..." "Product has been added to the cart!","Product has been added to the cart!" +"Product price is unknown, product cannot be added to the cart!","Product price is unknown, product cannot be added to the cart!" "Product quantity has been updated!","Product quantity has been updated!" -"Internal validation error. Please check if all required fields are filled in. Please contact us on {email}","Internal validation error. Please check if all required fields are filled in. Please contact us on {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.","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." "Product {productName} has been added to the compare!","Product {productName} has been added to the compare!" -"Product {productName} has been removed from compare!","Product {productName} has been removed from compare!" "Product {productName} has been added to wishlist!","Product {productName} has been added to wishlist!" -"Product {productName} has been removed from wishlit!","Product {productName} has been removed from wishlist!" -"Account data has successfully been updated","Account data has successfully been updated" -"Newsletter preferences have successfully been updated","Newsletter preferences have successfully been updated" +"Product {productName} has been removed from compare!","Product {productName} has been removed from compare!" +"Product {productName} has been removed from wishlist!","Product {productName} has been removed from wishlist!" +"Quantity available offline","Quantity (offline mode)" +"Quantity available","Quantity ({qty} available)" +"Quantity must be above 0","Quantity must be above 0" +"Quantity must be below {quantity}","Quantity must be below {quantity}" +"Quantity must be positive integer","Quantity must be positive integer" +"Registering the account ...","Registering the account ..." "Reset password feature does not work while offline!","Reset password feature does not work while offline!" -"You are logged in!","You are logged in!" -"Please fix the validation errors","Please fix the validation errors" -"Product price is unknown, product cannot be added to the cart!","Product price is unknown, product cannot be added to the cart!" -"My Account","My Account" -"Type what you are looking for...","Type what you are looking for..." -"Home Page","Home Page" -"Checkout","Checkout" +"Review","Review" +"Reviews","Reviews" +"Select 0","Select 0" +"Select 1","Select 1" +"Shopping cart is empty. Please add some products before entering Checkout","Shopping cart is empty. Please add some products before entering Checkout" +"Some of the ordered products are not available!","Some of the ordered products are not available!" +"Stock check in progress, please wait while available stock quantities are checked","Stock check in progress, please wait while available stock quantities are checked" "Subtotal incl. tax","Subtotal incl. tax" -"Grand total","Grand total" -"Field is required","Field is required" -"Field is required.","Field is required." -"You're logged out","You're logged out" -"Compare Products","Compare Products" -"404 Page Not Found","404 Page Not Found" -"Error with response - bad content-type!","Error with response - bad content-type!" +"Summary","Summary" +"The product is out of stock and cannot be added to the cart!","The product is out of stock and cannot be added to the cart!" +"The product, category or CMS page is not available in Offline mode. Redirecting to Home.","The product, category or CMS page is not available in Offline mode. Redirecting to Home." +"The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.","The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation." +"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.","There is no Internet connection. You can still place your order. We will notify you if any of ordered products is not avaiable because we cannot check it right now." +"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!" +"Thumbnail","Thumbnail" +"Type what you are looking for...","Type what you are looking for..." +"Unexpected authorization error. Check your Network conection.","Unexpected authorization error. Check your Network conection." "Unhandled error, wrong response format!","Unhandled error, wrong response format!" -"not authorized","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","Proceed to checkout" -"OK","OK" -"Out of the stock!","Out of the stock!" -"In stock!","In stock!" -"Please configure product custom options and fix the validation errors","Please configure product custom options and fix the validation errors" -"Error refreshing user token. User is not authorized to access the resource","Error refreshing user token. User is not authorized to access the resource" -"Must be greater than 0","Must be greater than 0" -"Please select the field which You like to sort by","Please select the field which You like to sort by" -"No available product variants","No available product variants" -"email","email" -"password","password" -"Confirm your order","Confirm your order" -"Please confirm order you placed when you was offline","Please confirm order you placed when you was offline" -"Payment Information","Payment Information" +"Vue Storefront", "Vue Storefront" +"You are going to pay for this order upon delivery.","You are going to pay for this order upon delivery." +"You are logged in!","You are logged in!" "You are to pay for this order upon delivery.","You are to pay for this order upon delivery." -"Allow notification about the order","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" +"You need to be logged in to see this page","You need to be logged in to see this page" +"You submitted your review for moderation.","You submitted your review for moderation." +"You're logged out","You're logged out" +"email","email" "have as many","have as many" -"Compare products","Compare products" -"Reviews","Reviews" -"Review","Review" -"Add review","Add review" -"Summary","Summary" "login","login" +"most you may purchase","most you may purchase" +"not authorized","not authorized" +"password","password" "to account","to account" -"Are you sure you would like to remove this item from the shopping cart?","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.","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","Please configure product bundle options and fix the validation errors" -"Processing order...","Processing order..." -"You need to be logged in to see this page","You need to be logged in to see this page" -"Quantity must be above 0","Quantity must be above 0" -"Error: Error while adding products","Error: Error while adding products" -"Unexpected authorization error. Check your Network conection.","Unexpected authorization error. Check your Network conection." -"Columns","Columns" diff --git a/core/i18n/resource/i18n/es-ES.csv b/core/i18n/resource/i18n/es-ES.csv index 01b400ca6..73c6f648a 100644 --- a/core/i18n/resource/i18n/es-ES.csv +++ b/core/i18n/resource/i18n/es-ES.csv @@ -1,39 +1,44 @@ -"Registering the account ...","Registrando la cuenta ..." +" is out of stock!"," está agotado!" +"404 Page Not Found","404 Pagina no encontrada" +"Account data has successfully been updated","Los datos de la cuenta se han actualizado con éxito" +"Adding a review ...","Adding a review ..." +"Are you sure you would like to remove this item from the shopping cart?","¿Está seguro de que desea eliminar este artículo de la cesta de la compra?" +"Checkout","Pagar" +"Compare Products","Comparar productos" +"Error with response - bad content-type!","Error con la respuesta - ¡tipo de contenido incorrecto!" +"Field is required","El campo es requerido" +"Field is required.","El campo es requerido." +"Grand total","Gran total" +"Home Page","Página de inicio" +"Internal validation error. Please check if all required fields are filled in. Please contact us on {email}","Error de validación interna. Por favor, compruebe si se completan todos los campos obligatorios. Póngase en contacto con nosotros en {email}" +"My Account","Mi Cuenta" +"Newsletter preferences have successfully been updated","Las preferencias del boletín se han actualizado con éxito" "No products synchronized for this category. Please come back while online!","No hay productos sincronizados para esta categoría. Por favor regrese mientras esta en linea!" -"Shopping cart is empty. Please add some products before entering Checkout","El Carro de Compras está Vacío. Por favor agregue algunos productos antes de ingresar al Checkout" -"Out of stock!","¡Agotado!" -" is out of the stock!"," está agotado!" -"Some of the ordered products are not available!","¡Algunos de los productos pedidos no están disponibles!" -"Stock check in progress, please wait while available stock quantities are checked","Verificación de stock en curso, espere mientras se verifican las cantidades de stock disponibles" -"There is no Internet connection. You can still place your order. We will notify you if any of ordered products is not avaiable because we cannot check it right now.","No hay conexión a internet. Todavía puedes hacer tu pedido. Le notificaremos si alguno de los productos solicitados no están disponible porque no podemos verificarlo ahora mismo." "No such configuration for the product. Please do choose another combination of attributes.","No hay tal configuración para el producto. Por favor, elija otra combinación de atributos." -"The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.","El sistema no está seguro acerca de la cantidad de stock (volátil). El producto ha sido agregado al carrito para pre-reserva." -"This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!","¡Esta característica aún no está implementada! ¡Por favor, eche un vistazo a https://github.com/DivanteLtd/vue-storefront/issues para nuestra Hoja de ruta!" -"The product is out of stock and cannot be added to the cart!","¡El producto no está disponible y no se puede agregar al carrito!" +"Only {maxQuantity} products of this type are available!","Only {maxQuantity} products of this type are available!" +"Out of stock!","¡Agotado!" +"Please fix the validation errors","Corrija los errores de validación" "Product has been added to the cart!","¡El producto ha sido agregado al carrito!" -"Internal validation error. Please check if all required fields are filled in. Please contact us on {email}","Error de validación interna. Por favor, compruebe si se completan todos los campos obligatorios. Póngase en contacto con nosotros en {email}" +"Product price is unknown, product cannot be added to the cart!","El precio del producto es desconocido, ¡el producto no se puede agregar al carrito!" +"Product quantity has been updated!","¡La cantidad del producto ha sido actualizada!" "Product {productName} has been added to the compare!","¡El producto {productName} se ha agregado a la comparación!" -"Product {productName} has been removed from compare!","¡El producto {productName} ha sido eliminado de la comparación!" "Product {productName} has been added to wishlist!","¡El producto {productName} ha sido agregado a la lista de deseos!" -"Product {productName} has been removed from wishlit!","¡El producto {productName} ha sido eliminado de la lista de deseos!" -"Account data has successfully been updated","Los datos de la cuenta se han actualizado con éxito" -"Newsletter preferences have successfully been updated","Las preferencias del boletín se han actualizado con éxito" +"Product {productName} has been removed from compare!","¡El producto {productName} ha sido eliminado de la comparación!" +"Product {productName} has been removed from wishlist!","¡El producto {productName} ha sido eliminado de la lista de deseos!" +"Registering the account ...","Registrando la cuenta ..." "Reset password feature does not work while offline!","¡La función Restablecer contraseña no funciona sin conexión!" -"You are logged in!","¡Has iniciado sesión!" -"Please fix the validation errors","Corrija los errores de validación" -"Product price is unknown, product cannot be added to the cart!","El precio del producto es desconocido, ¡el producto no se puede agregar al carrito!" -"My Account","Mi Cuenta" -"Type what you are looking for...","Escribe lo que estás buscando..." -"Home Page","Página de inicio" -"Checkout","Pagar" +"Shopping cart is empty. Please add some products before entering Checkout","El Carro de Compras está Vacío. Por favor agregue algunos productos antes de ingresar al Checkout" +"Some of the ordered products are not available!","¡Algunos de los productos pedidos no están disponibles!" +"Stock check in progress, please wait while available stock quantities are checked","Verificación de stock en curso, espere mientras se verifican las cantidades de stock disponibles" "Subtotal incl. tax","Subtotal incl. impuesto" -"Grand total","Gran total" -"Field is required","El campo es requerido" -"Field is required.","El campo es requerido." -"You're logged out","Estás desconectado" -"Compare Products","Comparar productos" -"404 Page Not Found","404 Pagina no encontrada" -"Error with response - bad content-type!","Error con la respuesta - ¡tipo de contenido incorrecto!" +"The product is out of stock and cannot be added to the cart!","¡El producto no está disponible y no se puede agregar al carrito!" +"The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.","El sistema no está seguro acerca de la cantidad de stock (volátil). El producto ha sido agregado al carrito para pre-reserva." +"There is no Internet connection. You can still place your order. We will notify you if any of ordered products is not avaiable because we cannot check it right now.","No hay conexión a internet. Todavía puedes hacer tu pedido. Le notificaremos si alguno de los productos solicitados no están disponible porque no podemos verificarlo ahora mismo." +"This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!","¡Esta característica aún no está implementada! ¡Por favor, eche un vistazo a https://github.com/DivanteLtd/vue-storefront/issues para nuestra Hoja de ruta!" +"Thumbnail","Thumbnail" +"Type what you are looking for...","Escribe lo que estás buscando..." "Unhandled error, wrong response format!","¡Error no controlado, formato de respuesta incorrecto!" -"Are you sure you would like to remove this item from the shopping cart?","¿Está seguro de que desea eliminar este artículo de la cesta de la compra?" +"You are logged in!","¡Has iniciado sesión!" "You need to be logged in to see this page","Necesitas iniciar sesión para ver esta página" +"You submitted your review for moderation.","You submitted your review for moderation." +"You're logged out","Estás desconectado" diff --git a/core/i18n/resource/i18n/et-EE.csv b/core/i18n/resource/i18n/et-EE.csv new file mode 100644 index 000000000..66f274991 --- /dev/null +++ b/core/i18n/resource/i18n/et-EE.csv @@ -0,0 +1,77 @@ +"Registering the account ...","Konto loomine…" +"No products synchronized for this category. Please come back while online!","Kategooria on tühi." +"Shopping cart is empty. Please add some products before entering Checkout","Otsukorv on tühi." +"Out of stock!","Laost otsas!" +" is out of the stock!"," on laost otsas!" +"Some of the ordered products are not available!","Mõned tellitud tooted ei ole kahjuks enam saadaval." +"Please wait ...","Palun oota…" +"Stock check in progress, please wait while available stock quantities are checked","Palun oota, kontrollime laoseise." +"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.","Interneti ühendus puudub. Saad sellegi poolest tellimuse luua. Kontrollime interneti ühenduse taastudes tellitud toodete laoseisu üle. Anname märku, kui mõni tellitud toodetest vahepeal otsa on saanud. " +"No such configuration for the product. Please do choose another combination of attributes.","Sellise kombinatsiooniga toodet ei saa tellida." +"The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.","Antud toodet ostetakse väga palju ja me ei ole laoseisu osas kindlad. Toode on ostukorvi lisatud ja Teie jaoks broneeritud." +"This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!","Sellist funktsionaalsust ei ole veel lisatud. Saad meie arendusplaanidega tutvuda https://github.com/DivanteLtd/vue-storefront/issues." +"The product is out of stock and cannot be added to the cart!","Toode on kahjuks laost otsa saanud." +"Product has been added to the cart!","Toode lisati ostukorvi." +"Product quantity has been updated!","Toote laoseis on uuendatud." +"Internal validation error. Please check if all required fields are filled in. Please contact us on {email}","Viga! Palun kontrolli, et kõik kohustuslikud väljad oleksid täidetud või kirjuta meile {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.","Ostuvormistamisel sisestatud aadress sisaldab vigu! Palun kontrolli, et kõik kohustuslikud väljad oleksid täidetud või kirjuta meile {email}." +"Product {productName} has been added to the compare!","{productName} lisati võrdlusesse." +"Product {productName} has been removed from compare!","{productName} võrdlusest eemaldatud." +"Product {productName} has been added to wishlist!","{productName} Lisati soovikorvi." +"Product {productName} has been removed from wishlit!","{productName} eemaldati soovikorvist" +"Account data has successfully been updated","Konto andmed uuendatud" +"Newsletter preferences have successfully been updated","Uudiskirjaga liitumine uuendatud" +"Reset password feature does not work while offline!","Parooli ei saa kahjuks ilma interneti ühenduseta muuta." +"You are logged in!","Olete sisse logitud." +"Please fix the validation errors","Palun parandage valideerimise vead" +"Product price is unknown, product cannot be added to the cart!","Tootel puudub hind. Toodet ei saa ostukorvi lisada." +"My Account","Minu konto" +"Type what you are looking for...",Otsi… +"Home Page",Esileht +Checkout,Ostuvormistamine +"Subtotal incl. tax","Kokku (sisaldab käibemaksu)" +"Grand total",Kokku +"Field is required",Kohustuslik +"Field is required.",Kohustuslik. +"You're logged out","Olete välja logitud" +"Compare Products","Võrdle tooteid" +"404 Page Not Found","404 Lehekülge ei leitud" +"Error with response - bad content-type!","Viga sisu laadimisel" +"Unhandled error, wrong response format!","Vale päringu formaat" +"not authorized","Ligipääs puudub" +"Internal Application error while refreshing the tokens. Please clear the storage and refresh page.","Tokeni värskendamisel esines viga. Palun tühjendage vahemälu ja uuendage lehekülge." +"Proceed to checkout","Vormista ost" +OK,Ok +"Out of the stock!","Laost otsas" +"In stock!",Laos +"Please configure product custom options and fix the validation errors","Palun valige sobiv toode" +"Error refreshing user token. User is not authorized to access the resource","Kasutaja tokeni uuendamisel esineb probleeme. Kasutajal puuduvad õigused antud lehele sisenemiseks" +"Must be greater than 0","Peab olema suurem, kui 0" +"Please select the field which You like to sort by","Palun valige sorteerimise viis" +"No available product variants","Toote valikvariandid puuduvad" +email,E-mail +password,Parool +"Confirm your order","Kinnitage oma tellimus" +"Please confirm order you placed when you was offline","Palun kinnitage oma tellimuse, mille ilma interneti ühenduseta varasemalt tegite" +"Payment Information","Makse informatsioon" +"You are to pay for this order upon delivery.","Saate makse teostada tellimuse kätte saamisel." +"Allow notification about the order","Luba tellimusge seotud teadete edastamine" +"Extension developers would like to thank you for placing an order!","Mooduli loojad tänavad tellimuse tegemise eest!" +"most you may purchase","maksimum ostu kogus" +"have as many","osta kuni" +"Compare products","Võrdle tooteid" +Reviews,Kommentaarid +Review,Kommentaar +"Add review","Lisa kommentaar" +Summary,Kokkuvõte +login,"logi sisse" +"to account",kontole +"Are you sure you would like to remove this item from the shopping cart?","Olete kindel, et soovite antud toote ostukorvist eemaldada?" +"The product, category or CMS page is not available in Offline mode. Redirecting to Home.","Antud toode, kategooria või sisuleht ei ole kahjuks ilma internetiühenduseta saadaval. Suuname ümber esilehele." +"Please configure product bundle options and fix the validation errors","Toode on valikutega. Palun valige sobivad valikud" +"Processing order...","Tellimuse loomine…" +"You need to be logged in to see this page","Palun logige lehe nägemisesse sisse" +"Quantity must be above 0","Laokogus peab olema suurem, kui 0" +"Error: Error while adding products","Viga toote lisamisel." +"Unexpected authorization error. Check your Network conection.","Internetiühenduse viga. Palun kontrollige oma internetiühendust." +Columns,Tulbad diff --git a/core/i18n/resource/i18n/fr-FR.csv b/core/i18n/resource/i18n/fr-FR.csv index 7cdaa7b74..fce1c240c 100644 --- a/core/i18n/resource/i18n/fr-FR.csv +++ b/core/i18n/resource/i18n/fr-FR.csv @@ -1,49 +1,54 @@ -"Registering the account ...","Enregistrement du compte..." +" is out of stock!"," n'est pas en stock !" +"404 Page Not Found","404 Page non trouvée" +"Account data has successfully been updated","Votre compte a été mis à jour avec succès" +"Adding a review ...","Adding a review ..." +"Are you sure you would like to remove this item from the shopping cart?","Etes-vous sûr de vouloir supprimer cet objet de votre panier ?" +"Checkout","Valider" +"Compare Products","Comparer les produits" +"Error refreshing user token. User is not authorized to access the resource","Une erreur est survenue. L'utilisateur n'est pas autorisé à accéder à cette ressource" +"Error with response - bad content-type!","Erreur avec réponse - mauvais type de contenu !" +"Field is required","Champ requis" +"Field is required.","Champ requis." +"Grand total","Total" +"Home Page","Page d'accueil" +"In stock!","En stock !" +"Internal Application error while refreshing the tokens. Please clear the storage and refresh page.","Une erreur interne est survenue. Veuillez nettoyer le stockage du site et rafraîchir la page." +"Internal validation error. Please check if all required fields are filled in. Please contact us on {email}","Erreur de validation interne Veuillez vérifier si tous les champs requis sont remplis. Veuillez nous contacter sur {email}" +"Must be greater than 0","Doit être supérieur à 0" +"My Account","Mon compte" +"Newsletter preferences have successfully been updated","Les préférences de la newsletter ont été mises à jour avec succès" "No products synchronized for this category. Please come back while online!","Aucun produit synchronisé dans cette catégorie. Merci de passer en ligne !" -"Shopping cart is empty. Please add some products before entering Checkout","Le panier d'achat est vide. Veuillez ajouter des produits avant de passer à la caisse" -"Out of stock!","Rupture de stock !" -" is out of the stock!"," n'est pas en stock !" -"Some of the ordered products are not available!","Certains des produits commandés ne sont pas disponibles !" -"Stock check in progress, please wait while available stock quantities are checked","Vérification du stock en cours, veuillez patienter pendant que les stocks disponibles sont vérifiés" -"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.","Il n'y a pas de connexion Internet. Vous pouvez toujours passer votre commande. Nous vous informerons si l'un des produits commandés n'est pas disponible car nous ne pouvons pas le vérifier en mode hors ligne." "No such configuration for the product. Please do choose another combination of attributes.","Aucune configuration de ce type pour le produit. Veuillez choisir une autre combinaison d'attributs." -"The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.","Le système n'est pas sûr de la quantité de stock (volatile). Le produit a été ajouté au panier pour la pré-réservation." -"This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!","Cette fonctionnalité n'est pas encore implémentée! Veuillez vous rendre sur https://github.com/DivanteLtd/vue-storefront/issues pour consulter notre Roadmap!" -"The product is out of stock and cannot be added to the cart!","Le produit est en rupture de stock et ne peut être ajouté au panier !" +"OK","OK" +"Only {maxQuantity} products of this type are available!","Only {maxQuantity} products of this type are available!" +"Out of stock!","Rupture de stock !" +"Out of the stock!","Épuisé !" +"Please configure product custom options and fix the validation errors","Veuillez configurer les options du produit et corriger les erreurs de validation" +"Please fix the validation errors","Veuillez corriger les erreurs de validation" +"Proceed to checkout","Passer la commande" "Product has been added to the cart!","Le produit a été ajouté au panier !" -"Internal validation error. Please check if all required fields are filled in. Please contact us on {email}","Erreur de validation interne Veuillez vérifier si tous les champs requis sont remplis. Veuillez nous contacter sur {email}" +"Product price is unknown, product cannot be added to the cart!","Le prix du produit est inconnu, le produit ne peut pas être ajouté au panier !" +"Product quantity has been updated!","La quantité de produit a été mise à jour!" "Product {productName} has been added to the compare!","Le produit {productName} a été ajouté au comparateur !" -"Product {productName} has been removed from compare!","Le produit {productName} a été supprimé du comparateur !" "Product {productName} has been added to wishlist!","Le produit {productName} a été ajouté à la liste des souhaits !" -"Product {productName} has been removed from wishlit!","Le produit {productName} a été supprimé de la liste des souhaits !" -"Account data has successfully been updated","Votre compte a été mis à jour avec succès" -"Newsletter preferences have successfully been updated","Les préférences de la newsletter ont été mises à jour avec succès" +"Product {productName} has been removed from compare!","Le produit {productName} a été supprimé du comparateur !" +"Product {productName} has been removed from wishlist!","Le produit {productName} a été supprimé de la liste des souhaits !" +"Registering the account ...","Enregistrement du compte..." "Reset password feature does not work while offline!","La fonction de réinitialisation du mot de passe ne fonctionne pas en mode hors ligne !" -"You are logged in!","Vous êtes authentifié !" -"Please fix the validation errors","Veuillez corriger les erreurs de validation" -"Product price is unknown, product cannot be added to the cart!","Le prix du produit est inconnu, le produit ne peut pas être ajouté au panier !" -"My Account","Mon compte" -"Type what you are looking for...","Saisissez votre recherche ..." -"Home Page","Page d'accueil" -"Checkout","Valider" +"Shopping cart is empty. Please add some products before entering Checkout","Le panier d'achat est vide. Veuillez ajouter des produits avant de passer à la caisse" +"Some of the ordered products are not available!","Certains des produits commandés ne sont pas disponibles !" +"Stock check in progress, please wait while available stock quantities are checked","Vérification du stock en cours, veuillez patienter pendant que les stocks disponibles sont vérifiés" "Subtotal incl. tax","Sous-total taxes incluses" -"Grand total","Total" -"Field is required","Champ requis" -"Field is required.","Champ requis." -"You're logged out","Vous êtes déconnecté" -"Compare Products","Comparer les produits" -"404 Page Not Found","404 Page non trouvée" -"Error with response - bad content-type!","Erreur avec réponse - mauvais type de contenu !" +"The product is out of stock and cannot be added to the cart!","Le produit est en rupture de stock et ne peut être ajouté au panier !" +"The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.","Le système n'est pas sûr de la quantité de stock (volatile). Le produit a été ajouté au panier pour la pré-réservation." +"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.","Il n'y a pas de connexion Internet. Vous pouvez toujours passer votre commande. Nous vous informerons si l'un des produits commandés n'est pas disponible car nous ne pouvons pas le vérifier en mode hors ligne." +"This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!","Cette fonctionnalité n'est pas encore implémentée! Veuillez vous rendre sur https://github.com/DivanteLtd/vue-storefront/issues pour consulter notre Roadmap!" +"Thumbnail","Thumbnail" +"Type what you are looking for...","Saisissez votre recherche ..." "Unhandled error, wrong response format!","Erreur non prise en charge, format de réponse incorrect !" "View all","Voir tout" -"not authorized","pas autorisé" -"Internal Application error while refreshing the tokens. Please clear the storage and refresh page.","Une erreur interne est survenue. Veuillez nettoyer le stockage du site et rafraîchir la page." -"Proceed to checkout","Passer la commande" -"OK","OK" -"Out of the stock!","Épuisé !" -"In stock!","En stock !" -"Please configure product custom options and fix the validation errors","Veuillez configurer les options du produit et corriger les erreurs de validation" -"Error refreshing user token. User is not authorized to access the resource","Une erreur est survenue. L'utilisateur n'est pas autorisé à accéder à cette ressource" -"Must be greater than 0","Doit être supérieur à 0" -"Are you sure you would like to remove this item from the shopping cart?","Etes-vous sûr de vouloir supprimer cet objet de votre panier ?" +"You are logged in!","Vous êtes authentifié !" "You need to be logged in to see this page","Vous devez être connecté pour voir cette page" +"You submitted your review for moderation.","You submitted your review for moderation." +"You're logged out","Vous êtes déconnecté" +"not authorized","pas autorisé" diff --git a/core/i18n/resource/i18n/it-IT.csv b/core/i18n/resource/i18n/it-IT.csv index f93a63ebb..0eaa53d82 100644 --- a/core/i18n/resource/i18n/it-IT.csv +++ b/core/i18n/resource/i18n/it-IT.csv @@ -1,52 +1,89 @@ -"Registering the account ...","Registrazione l'account..." -"No products synchronized for this category. Please come back while online!","Nessun prodotto in questa categoria. Verifica la connessione di rete e riprova!" -"Shopping cart is empty. Please add some products before entering Checkout","Il tuo carrello è vuoto. Aggiungi almeno un prodotto prima di procedere alla cassa" -"Out of stock!","Non disponibile" -" is out of the stock!"," non è disponibile!" -"Some of the ordered products are not available!","Alcuni dei prodotti ordinati non sono disponibili!" -"Stock check in progress, please wait while available stock quantities are checked","Verifica disponibilità in corso, attendere la verifica della quantità dei prodotti" -"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.","Non sei connesso ad internet ma puoi ugualmente procedere con l'ordine. Non è possibile verificare adesso la disponibilità dei prodotti, se qualcuno dei prodotti ordinati non sarà più disponibile te lo faremo sapere." -"No such configuration for the product. Please do choose another combination of attributes.","Configurazione del prodotto inesistente. Scegli un'altra combinazione di attributi" -"The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.","Il sistema non è sicuro della disponibilità del prodotto (volatile). Il prodotto è stato aggiunto al carrello per prenotazione" -"This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!","Questa funzionalità non è ancora stata implementata! Dai un'occhiata alla nostra roadmap qui https://github.com/DivanteLtd/vue-storefront/issues!" -"The product is out of stock and cannot be added to the cart!","Il prodotto non è più disponibile e non può essere aggiunto al carrello" -"Product has been added to the cart!","Il prodotto è stato aggiunto al carrello!" -"Internal validation error. Please check if all required fields are filled in. Please contact us on {email}","Errore di validazione interno. Se tutti i campi sono stati compilati contattare {email}" -"Product {productName} has been added to the compare!","Il prodotto {productName} è stato aggiunto al comparatore!" -"Product {productName} has been removed from compare!","Il prodotto {productName} è stato rimosso dal comparatore" -"Product {productName} has been added to wishlist!","Il prodotto {productName} è stato aggiunto alla lista dei desideri!" -"Product {productName} has been removed from wishlist!","Il prodotto {productName} è stato rimosso dalla lista dei desideri" +" is out of stock!"," non è disponibile!" +"404 Page Not Found","404 Pagina non trovata" "Account data has successfully been updated","Le tue informazioni sono state aggiornate con successo" -"Newsletter preferences have successfully been updated","Le tue preferenze sulla newsletter sono state aggiornate con successo" -"Reset password feature does not work while offline!","Non puoi reimpostare la password quando non sei in linea!" -"You are logged in!","Accesso eseguito!" -"Please fix the validation errors","Verifica di aver compilato correttamente tutti i campi" -"Product price is unknown, product cannot be added to the cart!","Il prezzo di questo prodotto è sconosciuto, non è possibile aggiungerlo al carrello!" -"My Account","Il mio account" -"Type what you are looking for...","Digita quello che stai cercando..." -"Home Page","Home" +"Add review","Aggiungi una recensione" +"Adding a review ...","Adding a review ..." +"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.","L'indirizzo fornito nel checkout contiene dati non validi. Per favore controlla di averi riempito tutti i campi obbligatori e contattaci all'indirizzo email {email} per risolvere questo problema in futuro. Il tuo ordine è stato cancellato." +"Allow notification about the order","Consenti notifiche sull'ordine" +"Are you sure you would like to remove this item from the shopping cart?","Sei sicuro di voler rimuovere questo articolo dal carrello?" "Checkout","Cassa" -"Subtotal incl. tax","Subtotale tasse incluse" -"Grand total","Totale" -"Field is required","Campo obbligatorio" -"Field is required.","Campo obbligatorio" -"You're logged out","Ti sei disconnesso" +"Columns","Colonne" "Compare Products","Confronta prodotti" -"404 Page Not Found","404 Pagina non trovata" +"Compare products","Confronta prodotti" +"Confirm your order","Conferma il tuo ordine" +"Error refreshing user token. User is not authorized to access the resource","Errore durante l'aggiornamento del token. L'utente non è autorizzato ad accedere alla risorsa" "Error with response - bad content-type!","Errore nella risposta - content-type non valido!" -"Unhandled error, wrong response format!","Errore inatteso, formato della risposta non valido!" -"not authorized","non autorizzato" +"Error: Error while adding products","Errore: c'è stato un errore durante l'aggiunta dei prodotti" +"Extension developers would like to thank you for placing an order!","Gli sviluppatori dell'estensione vorrebbero ringraziarti per aver fatto un ordine!" +"Field is required","Campo obbligatorio" +"Field is required.","Campo obbligatorio" +"Grand total","Totale" +"Home Page","Home" +"In stock!","Disponibile" "Internal Application error while refreshing the tokens. Please clear the storage and refresh page.","Errore interno dell'applicazione durante l'aggiornamento dei token. Per favore svuota lo storage e ricarica la pagina." -"Proceed to checkout","Vai alla cassa" +"Internal validation error. Please check if all required fields are filled in. Please contact us on {email}","Errore di validazione interno. Se tutti i campi sono stati compilati contattare {email}" +"Must be greater than 0","Deve essere maggiore di 0" +"My Account","Il mio account" +"Newsletter preferences have successfully been updated","Le tue preferenze sulla newsletter sono state aggiornate con successo" +"No available product variants","Non ci sono varianti disponibili" +"No products synchronized for this category. Please come back while online!","Nessun prodotto in questa categoria. Verifica la connessione di rete e riprova!" +"No such configuration for the product. Please do choose another combination of attributes.","Configurazione del prodotto inesistente. Scegli un'altra combinazione di attributi" "OK","OK" +"Only {maxQuantity} products of this type are available!","Only {maxQuantity} products of this type are available!" +"Or if you will stay on "Order confirmation" page, the order will be placed automatically without confirmation, once the internet connection will be back.","Oppure se rimarrai nella pagina di "Conferma ordine", l'ordine verrà automaticamente evaso senza conferma, una volta che la connessione sarà ripristinata." +"Out of stock!","Non disponibile" "Out of the stock!","Non disponibile" -"In stock!","Disponibile" +"Payment Information","Informazioni di pagamento" +"Please configure product bundle options and fix the validation errors","Per favore completa la configurazione del prodotto bundle e verifica la validazione dei dati" "Please configure product custom options and fix the validation errors","Per favore configura le opzioni personabilizzate del prodotto e verifica di aver compilato correttamente tutti i campi" -"Error refreshing user token. User is not authorized to access the resource","Errore durante l'aggiornamento del token. L'utente non è autorizzato ad accedere alla risorsa" -"Must be greater than 0","Deve essere maggiore di 0" +"Please confirm order you placed when you was offline","Per favore conferma l'ordine che hai fatto quando eri offline" +"Please fix the validation errors","Verifica di aver compilato correttamente tutti i campi" "Please select the field which You like to sort by","Scegli il campo con cui vuoi ordinare" -"No available product variants","Non ci sono varianti disponibili" +"Please wait ...","Attendere..." +"Proceed to checkout","Vai alla cassa" +"Processing order...","Ordine in corso..." +"Product has been added to the cart!","Il prodotto è stato aggiunto al carrello!" +"Product price is unknown, product cannot be added to the cart!","Il prezzo di questo prodotto è sconosciuto, non è possibile aggiungerlo al carrello!" +"Product quantity has been updated!","La quantità è stata aggiornata!" +"Product {productName} has been added to the compare!","Il prodotto {productName} è stato aggiunto al comparatore!" +"Product {productName} has been added to wishlist!","Il prodotto {productName} è stato aggiunto alla lista dei desideri!" +"Product {productName} has been removed from compare!","Il prodotto {productName} è stato rimosso dal comparatore" +"Product {productName} has been removed from wishlist!","Il prodotto {productName} è stato rimosso dalla lista dei desideri" +"Quantity available","Quantità ({qty} disponibile)" +"Quantity must be above 0","La quantità deve essere superiore a 0" +"Quantity must be below {quantity}","La quantità deve essere inferiore a {quantity}" +"Quantity must be positive integer","La quantità deve essere un numero positivo" +"Registering the account ...","Creazione dell'account..." +"Reset password feature does not work while offline!","Non puoi reimpostare la password quando non sei in linea!" +"Review","Recensione" +"Reviews","Recensioni" +"Select 0","Select 0" +"Select 1","Select 1" +"Shopping cart is empty. Please add some products before entering Checkout","Il tuo carrello è vuoto. Aggiungi almeno un prodotto prima di procedere alla cassa" +"Some of the ordered products are not available!","Alcuni dei prodotti ordinati non sono disponibili!" +"Stock check in progress, please wait while available stock quantities are checked","Verifica disponibilità in corso, attendere la verifica della quantità dei prodotti" +"Subtotal incl. tax","Subtotale tasse incluse" +"Summary","Riassunto" +"The product is out of stock and cannot be added to the cart!","Il prodotto non è più disponibile e non può essere aggiunto al carrello" +"The product, category or CMS page is not available in Offline mode. Redirecting to Home.","The product, category or CMS page is not available in Offline mode. Redirecting to Home." +"The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.","Il sistema non è sicuro della disponibilità del prodotto (volatile). Il prodotto è stato aggiunto al carrello per prenotazione" +"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.","Non sei connesso ad internet ma puoi ugualmente procedere con l'ordine. Non è possibile verificare adesso la disponibilità dei prodotti, se qualcuno dei prodotti ordinati non sarà più disponibile te lo faremo sapere." +"This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!","Questa funzionalità non è ancora stata implementata! Dai un'occhiata alla nostra roadmap qui https://github.com/DivanteLtd/vue-storefront/issues!" +"Thumbnail","Miniatura" +"Type what you are looking for...","Cerca nel catalogo..." +"Unexpected authorization error. Check your Network conection.","Errore di autorizzazione inaspettato. Verifica la tua connesione." +"Unhandled error, wrong response format!","Errore inatteso, formato della risposta non valido!" +"Vue Storefront", "Vue Storefront" +"You are going to pay for this order upon delivery.","Pagherai questo ordine alla consegna." +"You are logged in!","Accesso eseguito!" +"You are to pay for this order upon delivery.","Pagherai questo ordine alla consegna." +"You need to be logged in to see this page","Devi essere loggato per vedere questa pagina" +"You submitted your review for moderation.","You submitted your review for moderation." +"You're logged out","Ti sei disconnesso" "email","email" +"have as many","have as many" +"login","accedi" +"most you may purchase","most you may purchase" +"not authorized","non autorizzato" "password","password" -"Are you sure you would like to remove this item from the shopping cart?","Sei sicuro di voler rimuovere questo articolo dal carrello?" -"You need to be logged in to see this page","Devi essere loggato per vedere questa pagina" +"to account","al tuo account" diff --git a/core/i18n/resource/i18n/ja-JP.csv b/core/i18n/resource/i18n/ja-JP.csv index dcf151cf5..122a317f8 100644 --- a/core/i18n/resource/i18n/ja-JP.csv +++ b/core/i18n/resource/i18n/ja-JP.csv @@ -1,24 +1,25 @@ +" is out of stock!"," は在庫がありません!" "404 Page Not Found","404ページが見つかりません" "About us (Magento CMS)","弊社について(Magento CMS)" -"a chat","チャット" "Add a discount code","ディスカウントコードの追加" "Add discount code","ディスカウントコードの追加" "Add to cart","カートに追加" "Add to compare","比べるに追加" "Add to favorite","お気に入りに追加" +"Adding a review ...","Adding a review ..." "Allow notification about the order","注文に関する通知を送る" "Are you sure you would like to remove this item from the shopping cart?","この商品をショッピングカートから削除してもいいですか?" "Author","作者" -"Back","戻る" "Back to login","ログインに戻る" +"Back","戻る" "Billing address","請求書先住所" "Change my password","パスワードを変更する" "Choose your country","国を選択" "City","市町村名" "Clear","クリア" "Cms Page Sync","CMSページと同期" -"Compare products","商品を比べる" "Compare Products","商品を比べる" +"Compare products","商品を比べる" "Confirmation of receival","商品受け取りの確認" "Continue to payment","支払いへ進む" "Continue to shipping","買い物へ進む" @@ -27,17 +28,17 @@ "Current password *","現在のパスワード *" "Custom Cms Page","カスタムCMSページ" "Date and time","日時と時間" -"Discount","ディスカウント" "Discount code","ディスカウントコード" -"Edit","編集" +"Discount","ディスカウント" +"E-mail us at demo@vuestorefront.io with any questions, suggestions how we could improve products or shopping experience","質問や私達のサービスをさらに良くする改善提案のある方はdemo@vuestorefront.ioまでメールで連絡をお願いします。" "Edit newsletter preferences","ニュースレターの設定を編集" "Edit payment","支払いの編集" "Edit personal details","ユーザー情報の編集" "Edit shipping","配送先の編集" "Edit your profile","プロフィールの編集" "Edit your shipping details","配送情報の編集" +"Edit","編集" "Email address *","メールアドレス *" -"E-mail us at demo@vuestorefront.io with any questions, suggestions how we could improve products or shopping experience","質問や私達のサービスをさらに良くする改善提案のある方はdemo@vuestorefront.ioまでメールで連絡をお願いします。" "Enter your email to receive instructions on how to reset your password.","メールを読んでそこにあるパスワード再設定の指示に従ってください。" "Erin recommends","Erinのおすすめ" "Error while sending reset password e-mail","パスワード再設定のメールの送信でエラー" @@ -55,14 +56,13 @@ "I accept ","以下に同意します " "I accept terms and conditions","以下の利用規約に同意します" "I agree to","以下に合意します" -"Internal Server Error 500","500サーバーエラー" -" is out of the stock!"," は在庫がありません!" "I want to create an account","アカウント作成したい" "I want to generate an invoice for the company","この会社への請求書を作りたい" "I want to receive a newsletter, and agree to its terms","利用規約に合意してニュースレーターを受け取ります" +"Internal Server Error 500","500サーバーエラー" "Load more","もっと見る" -"Log in","ログイン" "Log in to your account","アカウントにログイン" +"Log in","ログイン" "Magazine","マガジン" "New Luma Yoga Collection","新しいLumaのヨガコレクション" "New password *","新しいパスワード *" @@ -71,17 +71,15 @@ "No products yet","商品はありません" "No reviews have been posted yet. Please don\","まだレビューは投稿されていません。" "No such configuration for the product. Please do choose another combination of attributes.","この商品へのこの構成はありません。別の組み合わせを試してみてください。" -"notification-progress-start","進捗お知らせ開始" +"Only {maxQuantity} products of this type are available!","Only {maxQuantity} products of this type are available!" "Open menu","メニューを開く" "Open microcart","マイクロカートを開く" "Open my account","アカウントを開く" "Open search panel","検索パネルを開く" "Open wishlist","欲しいものリストを開く" -"or","か" "Order ID","注文ID" -"Order informations","注文情報" "Order Summary","注文詳細" -"or write to us through","か私達に連絡してください" +"Order informations","注文情報" "Out of stock!","在庫はありません!" "Password must have at least 8 letters.","パスワードは英数半角で8文字以上である必要があります。" "Payment","支払い" @@ -90,32 +88,28 @@ "Please check if all data are correct","全てのデータが正しいかチェックしてください" "Please confirm order you placed when you was offline","オフライン中に注文した商品を確認してください" "Please select the field which You like to sort by","ソートしたい項目を選んでください" -"Price ","価格 " +"Product quantity has been updated!","製品の数量が更新されました!" "Products","商品" "Purchase","購入" -"Register","登録" -"register an account","アカウントを登録" "Register an account","アカウントを登録" +"Register","登録" "Remember me","ログインを覚える" "Remove from compare","比べるから外す" "Repeat new password *","新しいパスワード(もう一度) *" "Reset password feature does not work while offline!","オフラインの間ではパスワードの再設定はできません!" "Returns","返品" -"return to log in","戻ってログイン" -"Review","レビュー" "Review order","注文のレビュー" +"Review","レビュー" "Reviews","レビュー" "Safety","安全" "Sale","購入" -"search","検索" "See details","詳細を見る" "See our bestsellers","ベストセラーを見る" "Select color ","色の選択 " -"Select size ","サイズの選択 " -"Shipping address","配送用住所" "Ship to my default address","デフォルトの住所に配送する" -"Shopping cart","ショッピングカート" +"Shipping address","配送用住所" "Shopping cart is empty. Please add some products before entering Checkout","ショッピングカートが空です。支払いに進むには商品を追加してください。" +"Shopping cart","ショッピングカート" "Shopping summary","お買い物詳細" "Show subcategories","サブカテゴリーを表示" "Sign up to our newsletter and receive a coupon for 10% off!","ニュースレターに登録をして、10%OFFクーポンを手に入れましょう!" @@ -127,23 +121,23 @@ "Street name","通りの名前" "Subscribe to the newsletter and receive a coupon for 10% off","ニュースレターに登録をして、10%OFFクーポンを手に入れましょう!" "Summary","詳細" -"Tax","税金" "Tax ID *","税金ID *" +"Tax ID must have at least 3 letters.","税金登録番号は3桁以上である必要があります。" "Tax identification number *","税金登録番号 *" "Tax identification number must have at least 3 letters.","税金登録番号は3桁以上である必要があります。" -"Tax ID must have at least 3 letters.","税金登録番号は3桁以上である必要があります。" +"Tax","税金" "The new account will be created with the purchase. You will receive details on e-mail.","この注文後新しいアカウントが作られます。メールで詳細が送られます。" "The product is out of stock and cannot be added to the cart!","この商品は在庫がなく、カートに追加することができません!" -"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.","現在インターネットへ接続されていません。注文をすることはできますが、在庫がない場合はインターネットに接続後お知らせを送ります。" "The server order id has been set to ","サーバー注文IDが以下に設定されました " -"to find product you were looking for.","探している商品を見つける。" +"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.","現在インターネットへ接続されていません。注文をすることはできますが、在庫がない場合はインターネットに接続後お知らせを送ります。" +"Thumbnail","Thumbnail" "To finish the order just come back to our store while online. Your order will be sent to the server as soon as you come back here while online and then confirmed regarding the stock quantities of selected items","注文を終了するにはオンラインになったて再度確認する必要があります。オンラインになったら注文がサーバーに送られて在庫チェックが行われます。" -"Type","入力" "Type your opinion","意見を入力" -"Update","更新" +"Type","入力" "Update my preferences","設定を更新" "Update my profile","プロフィールを更新" "Update my shipping details","配送住所を更新" +"Update","更新" "Use my billing data","請求データを使う" "Value","価格" "View all","全てを見る" @@ -159,11 +153,20 @@ "You have been successfully subscribed to our newsletter!","ニュースレターへの購読が完了しました!" "You have no items to compare.","比べる商品がありません" "You have successfuly placed the order. You can check status of your order by using our delivery status feature. You will receive an order confirmation e-mail with details of your order and a link to track its progress.","注文が終わりました。注文の状態は注文ステータス機能から確認できます。注文の注文の状態を確認できるリンクの入った注文の詳細がメールで送られます。" +"You submitted your review for moderation.","You submitted your review for moderation." +"You will receive Push notification after coming back online. You can confirm the order by clicking on it","オンラインになるとプッシュ通知が送られ、そこから注文の確認ができます。" "Your Account","アカウント" "Your feedback is important for us. Let us know what we could improve.","あなたからのフィードバックは重要です。どこを改善できるか是非お伝えください。" "Your purchase","購入" "Your shopping cart is empty.","ショッピングカートは空です。" "Your wishlist is empty.","お気に入りリストは空です。" -"You will receive Push notification after coming back online. You can confirm the order by clicking on it","オンラインになるとプッシュ通知が送られ、そこから注文の確認ができます。" -"Zipcode","郵便番号" "Zip-code","郵便番号" +"Zipcode","郵便番号" +"a chat","チャット" +"notification-progress-start","進捗お知らせ開始" +"or write to us through","か私達に連絡してください" +"or","か" +"register an account","アカウントを登録" +"return to log in","戻ってログイン" +"search","検索" +"to find product you were looking for.","探している商品を見つける。" diff --git a/core/i18n/resource/i18n/nl-NL.csv b/core/i18n/resource/i18n/nl-NL.csv index 8042d3e05..2e40fee4b 100644 --- a/core/i18n/resource/i18n/nl-NL.csv +++ b/core/i18n/resource/i18n/nl-NL.csv @@ -1,39 +1,44 @@ -Registering the account ...,Account registreren ... -No products synchronized for this category. Please come back while online!,Er zijn geen producten gesynchroniseerd in deze categorie. Kom alstublieft terug terwijl u online bent. + is out of stock!, is niet in voorraad! +"Adding a review ...","Adding a review ..." +"Are you sure you would like to remove this item from the shopping cart?","Weet u zeker dat u dit artikel uit uw mandje wilt verwijderen?" +"Only {maxQuantity} products of this type are available!","Only {maxQuantity} products of this type are available!" +"Product price is unknown, product cannot be added to the cart!",Productprijs onbekend. Dit product kan niet worden toegevoegd aan de winkelwagen! +"Product quantity has been updated!","Produktmængde er blevet opdateret!" "Shopping cart is empty. Please add some products before entering Checkout",Uw winkelwagen is leeg. Voeg producten toe voordat u gaat afrekenen -Out of stock!,Niet in voorraad! - is out of the stock!, is niet in voorraad! -Some of the ordered products are not available!,Niet alle bestelde producten zijn beschikbaar! "Stock check in progress, please wait while available stock quantities are checked","Voorraadcontrole bezig. Een moment gedult alstublieft, we checken op dit moment de voorraden" -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.,"U heeft geen internetverbinding. U kunt nog steeds een bestelling plaatsen. We laten het weten als de door u bestelde producten niet op voorraad zijn, dit kunnen we momenteel niet controleren." +"Thumbnail","Thumbnail" +"Unhandled error, wrong response format!","Unhandled error, wrong response format!" +"You need to be logged in to see this page","U moet ingelogd zijn om deze pagina te bekijken" +"You submitted your review for moderation.","You submitted your review for moderation." +404 Page Not Found,404 Pagina niet gevonden +Account data has successfully been updated,Account gegevens succesvol geupdatet. +Checkout,Afrekenen +Compare Products,Producten vergelijken +Error with response - bad content-type!,Error with response - bad content-type! +Field is required,Veld is verplicht +Field is required.,Velden zijn verplicht. +Grand total,Eindtotaal +Home Page,Homepage +Internal validation error. Please check if all required fields are filled in. Please contact us on {email},Interne validatie fout. Check of alle verplichte velden ingevuld zijn. Neem contact met ons op via {email}. +My Account,Mijn Account +Newsletter preferences have successfully been updated,Uw nieuwsbrief voorkeur is gewijzigd. +No products synchronized for this category. Please come back while online!,Er zijn geen producten gesynchroniseerd in deze categorie. Kom alstublieft terug terwijl u online bent. No such configuration for the product. Please do choose another combination of attributes.,De gekozen productconfiguratie bestaat niet. Kies een andere combinatie. -The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.,Het is niet duidelijk hoeveel producten er exact op voorraad zijn. Het product is toegevoegd aan de winkelwagen als reservering. -This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!,"Helaas, deze feature is nog niet geimplementeerd. Kijk op https://github.com/DivanteLtd/vue-storefront/issues om de roadmap te zien." -The product is out of stock and cannot be added to the cart!,Dit product is niet in voorraad en kan niet toegevoegd worden aan de winkelwagen! +Out of stock!,Niet in voorraad! +Please fix the validation errors,Corrigeer de validatiefouten Product has been added to the cart!,Product is toegevoegd aan de winkelwagen! -Internal validation error. Please check if all required fields are filled in. Please contact us on {email},Interne validatie fout. Check of alle verplichte velden ingevuld zijn. Neem contact met ons op via {email}. Product {productName} has been added to the compare!,Product {productName} is toegevoegd aan de vergelijking! -Product {productName} has been removed from compare!,Product {productName} is verwijderd van de vergelijking! Product {productName} has been added to wishlist!,Product {productName} is toegevoegd aan de verlanglijst! -Product {productName} has been removed from wishlit!,Product {productName} is verwijderd van de verlanglijst -Account data has successfully been updated,Account gegevens succesvol geupdatet. -Newsletter preferences have successfully been updated,Uw nieuwsbrief voorkeur is gewijzigd. +Product {productName} has been removed from compare!,Product {productName} is verwijderd van de vergelijking! +Product {productName} has been removed from wishlist!,Product {productName} is verwijderd van de verlanglijst +Registering the account ...,Account registreren ... Reset password feature does not work while offline!,De reset wachtwoordfunctie werkt niet terwijl u offline bent! -You are logged in!,U bent ingelogd! -Please fix the validation errors,Corrigeer de validatiefouten -"Product price is unknown, product cannot be added to the cart!",Productprijs onbekend. Dit product kan niet worden toegevoegd aan de winkelwagen! -My Account,Mijn Account -Type what you are looking for...,Waar bent u naar op zoek? -Home Page,Homepage -Checkout,Afrekenen +Some of the ordered products are not available!,Niet alle bestelde producten zijn beschikbaar! Subtotal incl. tax,Subtotaal incl. BTW -Grand total,Eindtotaal -Field is required,Veld is verplicht -Field is required.,Velden zijn verplicht. +The product is out of stock and cannot be added to the cart!,Dit product is niet in voorraad en kan niet toegevoegd worden aan de winkelwagen! +The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.,Het is niet duidelijk hoeveel producten er exact op voorraad zijn. Het product is toegevoegd aan de winkelwagen als reservering. +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.,"U heeft geen internetverbinding. U kunt nog steeds een bestelling plaatsen. We laten het weten als de door u bestelde producten niet op voorraad zijn, dit kunnen we momenteel niet controleren." +This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!,"Helaas, deze feature is nog niet geimplementeerd. Kijk op https://github.com/DivanteLtd/vue-storefront/issues om de roadmap te zien." +Type what you are looking for...,Waar bent u naar op zoek? +You are logged in!,U bent ingelogd! You're logged out,Je bent uitgelogd -Compare Products,Producten vergelijken -404 Page Not Found,404 Pagina niet gevonden -Error with response - bad content-type!,Error with response - bad content-type! -"Unhandled error, wrong response format!","Unhandled error, wrong response format!" -"Are you sure you would like to remove this item from the shopping cart?","Weet u zeker dat u dit artikel uit uw mandje wilt verwijderen?" -"You need to be logged in to see this page","U moet ingelogd zijn om deze pagina te bekijken" diff --git a/core/i18n/resource/i18n/pl-PL.csv b/core/i18n/resource/i18n/pl-PL.csv index ffc30e7b6..27074b4bd 100644 --- a/core/i18n/resource/i18n/pl-PL.csv +++ b/core/i18n/resource/i18n/pl-PL.csv @@ -1,48 +1,53 @@ -"Registering the account ...","Tworzenie konta ..." -"No products synchronized for this category. Please come back while online!","Brak produktów dla tej kategorii. Spróbuj ponownie po uzyskaniu dostępu do Internetu!" -"Shopping cart is empty. Please add some products before entering Checkout","Koszyk jest pusty. Dodaj produkty." -"Out of stock!","Produkt niedostępny!" -" is out of the stock!"," jest niedostępny!" -"Some of the ordered products are not available!","Niektóre z produktów są niedostępne!" -"Stock check in progress, please wait while available stock quantities are checked","Trwa sprawdzanie dostępności produktów, proszę czekać" -"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.","Brak połączenia z Internetem. Możesz nadal złożyć zamówienie. Poinformujemy Cię jeśli któryś z produktów nie będzie dostępny." -"No such configuration for the product. Please do choose another combination of attributes.","Wybrane opcje są niedostępne. Wybierz inne opcje." -"The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.","The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation." -"This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!","Wybrana opcja jest niedostępna!" -"The product is out of stock and cannot be added to the cart!","Produkt jest niedostępny!" -"Product has been added to the cart!","Produkt został dodany do koszyka!" -"Internal validation error. Please check if all required fields are filled in. Please contact us on {email}","Błąd walidacji. Sprawdź czy wszystkie wymagane pola zostały wybrane. Proszę, skontaktuj się z nami na adres {email}" -"Product {productName} has been added to the compare!","Produkt {productName} został dodany do porównania!" -"Product {productName} has been removed from compare!","Produkt {productName} został usunięty z porównania!" -"Product {productName} has been added to wishlist!","Produkt {productName} został dodany do listy życzeń!" -"Product {productName} has been removed from wishlit!","Produkt {productName} został usunięty z listy życzeń!" +" is out of stock!"," jest niedostępny!" +"404 Page Not Found","404 Nie ma takiej strony" "Account data has successfully been updated","Twoje dane zostały zaktualizowane" -"Newsletter preferences have successfully been updated","Twoje ustawienia subskrypcji zostały zaktualizowane" -"Reset password feature does not work while offline!","Aby zresetowa hasło musisz posiadać połączenie z Internetem!" -"You are logged in!","Jesteś zalogowany!" -"Please fix the validation errors","Popraw błędy walidacji" -"Product price is unknown, product cannot be added to the cart!","Nie można dodać produktu do koszyka, nie można potwierdzić ceny produktu!" -"My Account","Moje konto" -"Type what you are looking for...","Znajdź..." -"Home Page","Strona główna" +"Adding a review ...","Adding a review ..." +"Are you sure you would like to remove this item from the shopping cart?","Czy na pewno chcesz usunąć ten produkt z koszyka?" "Checkout","Płatność" -"Subtotal incl. tax","Kwota częściowa brutto" -"Grand total","Łączna suma" -"Field is required","Pole jest wymagane" -"Field is required.","Pole jest wymagane." -"You're logged out","Jesteś wylogowany" "Compare Products","Porównaj produkty" -"404 Page Not Found","404 Nie ma takiej strony" +"Error refreshing user token. User is not authorized to access the resource","Błąd odświeżania tokenu użytkownika. Użytkownik nie dostępu do zasobu" "Error with response - bad content-type!","Błąd odpowiedzi - bad content-type!" -"Unhandled error, wrong response format!","Nieobsługiwany błąd, nieprawidłowy format odpowiedzi!" -"not authorized","nieautoryzowany" +"Field is required","Pole jest wymagane" +"Field is required.","Pole jest wymagane." +"Grand total","Łączna suma" +"Home Page","Strona główna" +"In stock!","W magazynie!" "Internal Application error while refreshing the tokens. Please clear the storage and refresh page.","Błąd odświeżania tokenów. Proszę wyczyścić pamięć podręczną przeglądarki." -"Proceed to checkout","Przejdź do kasy" +"Internal validation error. Please check if all required fields are filled in. Please contact us on {email}","Błąd walidacji. Sprawdź czy wszystkie wymagane pola zostały wybrane. Proszę, skontaktuj się z nami na adres {email}" +"Must be greater than 0","Musi być większa niż 0" +"My Account","Moje konto" +"Newsletter preferences have successfully been updated","Twoje ustawienia subskrypcji zostały zaktualizowane" +"No products synchronized for this category. Please come back while online!","Brak produktów dla tej kategorii. Spróbuj ponownie po uzyskaniu dostępu do Internetu!" +"No such configuration for the product. Please do choose another combination of attributes.","Wybrane opcje są niedostępne. Wybierz inne opcje." "OK","OK" +"Only {maxQuantity} products of this type are available!","Only {maxQuantity} products of this type are available!" +"Out of stock!","Produkt niedostępny!" "Out of the stock!","Brak w magazynie!" -"In stock!","W magazynie!" "Please configure product custom options and fix the validation errors","Skonfiguruj opcje produktu i popraw będy walidacji" -"Error refreshing user token. User is not authorized to access the resource","Błąd odświeżania tokenu użytkownika. Użytkownik nie dostępu do zasobu" -"Must be greater than 0","Musi być większa niż 0" -"Are you sure you would like to remove this item from the shopping cart?","Czy na pewno chcesz usunąć ten produkt z koszyka?" +"Please fix the validation errors","Popraw błędy walidacji" +"Proceed to checkout","Przejdź do kasy" +"Product has been added to the cart!","Produkt został dodany do koszyka!" +"Product price is unknown, product cannot be added to the cart!","Nie można dodać produktu do koszyka, nie można potwierdzić ceny produktu!" +"Product quantity has been updated!","Ilość produktu została zaktualizowana!" +"Product {productName} has been added to the compare!","Produkt {productName} został dodany do porównania!" +"Product {productName} has been added to wishlist!","Produkt {productName} został dodany do listy życzeń!" +"Product {productName} has been removed from compare!","Produkt {productName} został usunięty z porównania!" +"Product {productName} has been removed from wishlist!","Produkt {productName} został usunięty z listy życzeń!" +"Registering the account ...","Tworzenie konta ..." +"Reset password feature does not work while offline!","Aby zresetowa hasło musisz posiadać połączenie z Internetem!" +"Shopping cart is empty. Please add some products before entering Checkout","Koszyk jest pusty. Dodaj produkty." +"Some of the ordered products are not available!","Niektóre z produktów są niedostępne!" +"Stock check in progress, please wait while available stock quantities are checked","Trwa sprawdzanie dostępności produktów, proszę czekać" +"Subtotal incl. tax","Kwota częściowa brutto" +"The product is out of stock and cannot be added to the cart!","Produkt jest niedostępny!" +"The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.","The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation." +"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.","Brak połączenia z Internetem. Możesz nadal złożyć zamówienie. Poinformujemy Cię jeśli któryś z produktów nie będzie dostępny." +"This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!","Wybrana opcja jest niedostępna!" +"Thumbnail","Miniaturka" +"Type what you are looking for...","Znajdź..." +"Unhandled error, wrong response format!","Nieobsługiwany błąd, nieprawidłowy format odpowiedzi!" +"You are logged in!","Jesteś zalogowany!" "You need to be logged in to see this page","Musisz być zalogowany, aby zobaczyć tę stronę" +"You submitted your review for moderation.","You submitted your review for moderation." +"You're logged out","Jesteś wylogowany" +"not authorized","nieautoryzowany" diff --git a/core/i18n/resource/i18n/pt-BR.csv b/core/i18n/resource/i18n/pt-BR.csv index c499f3d31..b7e34b452 100644 --- a/core/i18n/resource/i18n/pt-BR.csv +++ b/core/i18n/resource/i18n/pt-BR.csv @@ -1,43 +1,48 @@ -"Registering the account ...","Salvando a conta ..." -"No products synchronized for this category. Please come back while online!","Nenhum produto sincronizado para essa categoria. Por favor volte enquanto estiver online!" -"Shopping cart is empty. Please add some products before entering Checkout","Carrinho está vazio. Por favor adicione alguns produtos antes de Finalizar a Compra" -"Out of stock!","Fora de estoque!" -" is out of the stock!"," está fora de estoque!" -"Some of the ordered products are not available!","Alguns dos produtos ordenados não estão disponíveis!" -"Stock check in progress, please wait while available stock quantities are checked","Estamos verificando o estoque, por favor aguarde até que verificamos a quantidade solicitada em estoque." -"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.","Sem conexão de Internet. Você ainda consegue finalizar seu pedido. Nós iremos notificar você se algum dos produtos solicitados não estão disponíveis, porque no momento não podemos verificar isso." -"No such configuration for the product. Please do choose another combination of attributes.","Nenhuma configuração desse tipo para o produto. Por favor selecione outra configuração de opções." -"The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.","O sistema não tem certeza sobre a quantidade em estoque (volátil). O produto foi adicionado ao carrinho em pré-reserva." -"This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!","Essa funcionalidade não foi implementada ainda! Por favor dê uma olhada em https://github.com/DivanteLtd/vue-storefront/issues nosso planejamento!" -"The product is out of stock and cannot be added to the cart!","Produto não possui estoque e não pode ser adicionado ao carrinho!" -"Product has been added to the cart!","Produto foi adicionado ao carrinho!" -"Internal validation error. Please check if all required fields are filled in. Please contact us on {email}","Erro interno de validação. Por favor verifique se todos os campos obrigatórios estão preenchidos. Por favor contate-nos em {email}" -"Product {productName} has been added to the compare!","Produto {productName} foi adicionado para comparação!" -"Product {productName} has been removed from compare!","Produto {productName} foi removido da comparação!" -"Product {productName} has been added to wishlist!","Produto {productName} foi adicionado à lista de desejos!" -"Product {productName} has been removed from wishlit!","Produto {productName} foi removido da lista de desejos!" +" is out of stock!"," está sem estoque!" +"404 Page Not Found","404 Página Não Encontrada" "Account data has successfully been updated","Dados da conta foram atualizados" -"Newsletter preferences have successfully been updated","Configurações de Newsletter foram atualizadas." -"Reset password feature does not work while offline!","Funcionalidade de Recuperação de Senha não funciona sem conexão de internet!" -"You are logged in!","Você está conectado!" -"Please fix the validation errors","Por favor corrija os erros de validação" -"Product price is unknown, product cannot be added to the cart!","Preço do Produto é desconhecido, produto não pode ser adicionado ao carrinho!" -"My Account","Minha Conta" -"Type what you are looking for...","Digite o que você está buscando..." -"Home Page","Página Inicial" +"Adding a review ...","Adding a review ..." +"Are you sure you would like to remove this item from the shopping cart?","Tem certeza de que deseja remover este item do carrinho de compras?" "Checkout","Finalizar Compra" -"Subtotal incl. tax","Subtotal c/ imposto" -"Grand total","Total" -"Field is required","Campo é obrigatório" -"Field is required.","Campo é obrigatório." -"You're logged out","Você foi desconectado" "Compare Products","Comparar Produtos" -"404 Page Not Found","404 Página Não Encontrada" "Error with response - bad content-type!","Erro na resposta do servidor - bad content-type!" -"Unhandled error, wrong response format!","Erro desconhecido, formato de retorno errado!" -"not authorized","não autorizado" +"Field is required","Campo obrigatório" +"Field is required.","Campo obrigatório." +"Grand total","Total" +"Home Page","Página Inicial" "Internal Application error while refreshing the tokens. Please clear the storage and refresh page.","Erro Interno da Aplicação ao atualizar os tokens. Por favor limpe o cache do seu navegador e atualize a página." -"Proceed to checkout","Ir para Finalização de Compra" +"Internal validation error. Please check if all required fields are filled in. Please contact us on {email}","Erro interno de validação. Por favor verifique se todos os campos obrigatórios estão preenchidos. Por favor contate-nos em {email}" +"My Account","Minha Conta" +"Newsletter preferences have successfully been updated","Configurações de Newsletter foram atualizadas." +"No products synchronized for this category. Please come back while online!","Nenhum produto sincronizado para essa categoria. Por favor volte quando estiver online!" +"No such configuration for the product. Please do choose another combination of attributes.","Nenhuma configuração desse tipo disponível para o produto. Por favor selecione outra combinação de opções." "OK","OK" -"Are you sure you would like to remove this item from the shopping cart?","Tem certeza de que deseja remover este item do carrinho de compras?" +"Only {maxQuantity} products of this type are available!","Only {maxQuantity} products of this type are available!" +"Out of stock!","Sem estoque!" +"Please fix the validation errors","Por favor corrija os erros de validação" +"Proceed to checkout","Finalizar Compra" +"Product has been added to the cart!","Produto foi adicionado ao carrinho!" +"Product price is unknown, product cannot be added to the cart!","Preço do produto é desconhecido, produto não pode ser adicionado ao carrinho!" +"Product quantity has been updated!","A quantidade do produto foi atualizada!" +"Product {productName} has been added to the compare!","Produto {productName} foi adicionado para comparação!" +"Product {productName} has been added to wishlist!","Produto {productName} foi adicionado à lista de desejos!" +"Product {productName} has been removed from compare!","Produto {productName} foi removido da comparação!" +"Product {productName} has been removed from wishlist!","Produto {productName} foi removido da lista de desejos!" +"Registering the account ...","Salvando a conta ..." +"Reset password feature does not work while offline!","Recuperação de Senha não funciona sem conexão de internet!" +"Shopping cart is empty. Please add some products before entering Checkout","Carrinho está vazio. Por favor adicione produtos antes de Finalizar a Compra" +"Some of the ordered products are not available!","Alguns dos produtos comprados não estão disponíveis!" +"Stock check in progress, please wait while available stock quantities are checked","Estamos verificando o estoque, por favor aguarde até verificarmos a quantidade solicitada." +"Subtotal incl. tax","Subtotal c/ imposto" +"The product is out of stock and cannot be added to the cart!","Produto não possui estoque e não pode ser adicionado ao carrinho!" +"The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.","O sistema não tem certeza sobre a quantidade em estoque (volátil). O produto foi adicionado ao carrinho em pré-reserva." +"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.","Sem conexão de Internet. Você ainda pode finalizar seu pedido. Assim que possível o notificaremos se os produtos solicitados não estiverem disponíveis." +"This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!","Essa funcionalidade não foi implementada ainda! Por favor verifique nosso planejamento em https://github.com/DivanteLtd/vue-storefront/issues" +"Thumbnail","Thumbnail" +"Type what you are looking for...","Digite o que você está buscando..." +"Unhandled error, wrong response format!","Erro desconhecido, formato de retorno errado!" +"You are logged in!","Você está conectado!" "You need to be logged in to see this page","Você precisa estar logado para ver esta página" +"You submitted your review for moderation.","You submitted your review for moderation." +"You're logged out","Você foi desconectado" +"not authorized","não autorizado" diff --git a/core/i18n/resource/i18n/pt-PT.csv b/core/i18n/resource/i18n/pt-PT.csv index bdeb4b8e7..9bd2aea39 100644 --- a/core/i18n/resource/i18n/pt-PT.csv +++ b/core/i18n/resource/i18n/pt-PT.csv @@ -1,43 +1,48 @@ -"Registering the account ...","A registar a conta ..." +" is out of stock!"," está em ruptura de stock!" +"404 Page Not Found","404 Página Não Encontrada" +"Account data has successfully been updated","Os dados da conta foram atualizados" +"Adding a review ...","Adding a review ..." +"Are you sure you would like to remove this item from the shopping cart?","Tem a certeza de que deseja remover este item do Cesto de Compras?" +"Checkout","Finalizar Compra" +"Compare Products","Comparar Produtos" +"Error with response - bad content-type!","Erro na resposta do servidor - bad content-type!" +"Field is required","Campo obrigatório" +"Field is required.","Campo obrigatório." +"Grand total","Total" +"Home Page","Página Inicial" +"Internal Application error while refreshing the tokens. Please clear the storage and refresh page.","Erro Interno da Aplicação ao atualizar as tokens. Por favor limpe o cache do seu browser e recarregue a página." +"Internal validation error. Please check if all required fields are filled in. Please contact us on contributors@vuestorefront.io","Erro interno de validação. Por favor verifique se todos os campos obrigatórios estão preenchidos. Por favor contate-nos em contributors@vuestorefront.io" +"My Account","A Minha Conta" +"Newsletter preferences have successfully been updated","As suas preferências para a Newsletter foram atualizadas." "No products synchronized for this category. Please come back while online!","Nenhum produto sincronizado para esta categoria. Por favor volte qundo estiver online!" -"Shopping cart is empty. Please add some products before entering Checkout","O Cesto de Compras está vazio. Por favor adicione alguns produtos antes de Finalizar Compra" -"Out of stock!","Ruptura de stock!" -" is out of the stock!"," está em ruptura de stock!" -"Some of the ordered products are not available!","Alguns dos produtos solicitados não estão disponíveis!" -"Stock check in progress, please wait while available stock quantities are checked","Estamos a verificar o stock, por favor aguarde até que verifiquemos se a quantidade solicitada está disponível." -"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.","Sem ligação à Internet. Mas pode finalizar o seu pedido. Assim que possível iremos notifica-lo caso algum dos produtos solicitados não estiverem disponíveis." "No such configuration for the product. Please do choose another combination of attributes.","Esta configuração não é possível para este produto. Por favor selecione outra configuração de opções." -"The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.","O sistema não tem certeza sobre a quantidade em stock (volátil). O produto foi adicionado ao Cesto de Compras em pré-reserva." -"This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!","Esta funcionalidade ainda não foi implementada! Por favor consulte o nosso Rodmap em https://github.com/DivanteLtd/vue-storefront/issues!" -"The product is out of stock and cannot be added to the cart!","O produto não se encontra em stock e não pode ser adicionado ao Cesto de Compras!" +"OK","OK" +"Only {maxQuantity} products of this type are available!","Only {maxQuantity} products of this type are available!" +"Out of stock!","Ruptura de stock!" +"Please fix the validation errors","Por favor corrija os erros de validação" +"Proceed to checkout","Ir para Finalizar Compra" "Product has been added to the cart!","O produto foi adicionado ao Cesto de Compras!" -"Internal validation error. Please check if all required fields are filled in. Please contact us on contributors@vuestorefront.io","Erro interno de validação. Por favor verifique se todos os campos obrigatórios estão preenchidos. Por favor contate-nos em contributors@vuestorefront.io" +"Product price is unknown, product cannot be added to the cart!","O preço do produto é desconhecido, o produto não pode ser adicionado ao Cesto de Compras!" +"Product quantity has been updated!","A quantidade do produto foi atualizada!" "Product {productName} has been added to the compare!","Produto {productName} foi adicionado a Comparar Produtos!" -"Product {productName} has been removed from compare!","Produto {productName} foi removido de Comparar Produtos!" "Product {productName} has been added to wishlist!","Produto {productName} foi adicionado à Lista de Desejos!" -"Product {productName} has been removed from wishlit!","Produto {productName} foi removido da Lista de Desejos!" -"Account data has successfully been updated","Os dados da conta foram atualizados" -"Newsletter preferences have successfully been updated","As suas preferências para a Newsletter foram atualizadas." +"Product {productName} has been removed from compare!","Produto {productName} foi removido de Comparar Produtos!" +"Product {productName} has been removed from wishlist!","Produto {productName} foi removido da Lista de Desejos!" +"Registering the account ...","A registar a conta ..." "Reset password feature does not work while offline!","A funcionalidade de Recuperação de Senha não funciona sem ligação à internet!" -"You are logged in!","Você está com sessão inicializada!" -"Please fix the validation errors","Por favor corrija os erros de validação" -"Product price is unknown, product cannot be added to the cart!","O preço do produto é desconhecido, o produto não pode ser adicionado ao Cesto de Compras!" -"My Account","A Minha Conta" -"Type what you are looking for...","Escreva o que está procurando..." -"Home Page","Página Inicial" -"Checkout","Finalizar Compra" +"Shopping cart is empty. Please add some products before entering Checkout","O Cesto de Compras está vazio. Por favor adicione alguns produtos antes de Finalizar Compra" +"Some of the ordered products are not available!","Alguns dos produtos solicitados não estão disponíveis!" +"Stock check in progress, please wait while available stock quantities are checked","Estamos a verificar o stock, por favor aguarde até que verifiquemos se a quantidade solicitada está disponível." "Subtotal incl. tax","Subtotal c/ imposto" -"Grand total","Total" -"Field is required","Campo obrigatório" -"Field is required.","Campo obrigatório." -"You're logged out","Você não está com sessão inicializada" -"Compare Products","Comparar Produtos" -"404 Page Not Found","404 Página Não Encontrada" -"Error with response - bad content-type!","Erro na resposta do servidor - bad content-type!" +"The product is out of stock and cannot be added to the cart!","O produto não se encontra em stock e não pode ser adicionado ao Cesto de Compras!" +"The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.","O sistema não tem certeza sobre a quantidade em stock (volátil). O produto foi adicionado ao Cesto de Compras em pré-reserva." +"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.","Sem ligação à Internet. Mas pode finalizar o seu pedido. Assim que possível iremos notifica-lo caso algum dos produtos solicitados não estiverem disponíveis." +"This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!","Esta funcionalidade ainda não foi implementada! Por favor consulte o nosso Rodmap em https://github.com/DivanteLtd/vue-storefront/issues!" +"Thumbnail","Thumbnail" +"Type what you are looking for...","Escreva o que está procurando..." "Unhandled error, wrong response format!","Erro desconhecido, formato de resposta errado!" -"not authorized","não autorizado" -"Internal Application error while refreshing the tokens. Please clear the storage and refresh page.","Erro Interno da Aplicação ao atualizar as tokens. Por favor limpe o cache do seu browser e recarregue a página." -"Proceed to checkout","Ir para Finalizar Compra" -"OK","OK" -"Are you sure you would like to remove this item from the shopping cart?","Tem a certeza de que deseja remover este item do Cesto de Compras?" +"You are logged in!","Você está com sessão inicializada!" "You need to be logged in to see this page","Precisa de iniciar sessão para ver esta página" +"You submitted your review for moderation.","You submitted your review for moderation." +"You're logged out","Você não está com sessão inicializada" +"not authorized","não autorizado" diff --git a/core/i18n/resource/i18n/ru-RU.csv b/core/i18n/resource/i18n/ru-RU.csv index c2a5609c8..68392e1d3 100644 --- a/core/i18n/resource/i18n/ru-RU.csv +++ b/core/i18n/resource/i18n/ru-RU.csv @@ -1,39 +1,44 @@ -"Registering the account ...","Создается учетная запись ..." +" is out of stock!"," нет в наличии!" +"404 Page Not Found","404 Страница не найдена" +"Account data has successfully been updated","Данные учетной записи успешно обновлены" +"Adding a review ...","Adding a review ..." +"Are you sure you would like to remove this item from the shopping cart?","Вы уверены, что хотите удалить этот товар из корзины?" +"Checkout","Оформление заказа" +"Compare Products","Сравнить товары" +"Error with response - bad content-type!","Ошибка в ответе - плохой content-type!" +"Field is required","Обязательное поле" +"Field is required.","Обязательное поле." +"Grand total","Общий итог" +"Home Page","Главная страница" +"Internal validation error. Please check if all required fields are filled in. Please contact us on {email}","Внутренняя валидационная ошибка. Пожалуйста проверьте заполненность всех обязательных полей. Пожалуйста свяжитесь с нами по {email}" +"My Account","Личный кабинет" +"Newsletter preferences have successfully been updated","Предпочтения по новостям успешно обновлены" "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!","Некоторые из заказанных товаров не доступны!" -"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.","Система не может удостовериться в наличии необходимого количества (изменчиво). Товар добавлен в корзину для предварительного бронирования." -"This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!","Данная возможность пока не реализована! Пожалуйста ознакомьтесь с нашей дорожной картой тут: https://github.com/DivanteLtd/vue-storefront/issues" -"The product is out of stock and cannot be added to the cart!","Товара нет в наличии и его нельзя добавить в корзину" +"Only {maxQuantity} products of this type are available!","Only {maxQuantity} products of this type are available!" +"Out of stock!","Нет в наличии!" +"Please fix the validation errors","Пожалуйста исправьте валидационные ошибки" "Product has been added to the cart!","Товар добавлен в корзину!" -"Internal validation error. Please check if all required fields are filled in. Please contact us on {email}","Внутренняя валидационная ошибка. Пожалуйста проверьте заполненность всех обязательных полей. Пожалуйста свяжитесь с нами по {email}" +"Product price is unknown, product cannot be added to the cart!","Цена товара неизвестна, товар нельзя добавить в корзину!" +"Product quantity has been updated!","Количество товара обновлено!" "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","Предпочтения по новостям успешно обновлены" +"Product {productName} has been removed from compare!","Товар {productName} удален из страницы сравнения!" +"Product {productName} has been removed from wishlist!","Товар {productName} удален из списка пожеланий!" +"Registering the account ...","Создается учетная запись ..." "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","Оформление заказа" +"Shopping cart is empty. Please add some products before entering Checkout","Корзина пустая. Пожалуйста добавьте товары перед оформлением заказа" +"Some of the ordered products are not available!","Некоторые из заказанных товаров не доступны!" +"Stock check in progress, please wait while available stock quantities are checked","Производится проверка товаров на наличие, пожалуйста дождитесь завершения процесса проверки доступного количества" "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!","Ошибка в ответе - плохой content-type!" +"The product is out of stock and cannot be added to the cart!","Товара нет в наличии и его нельзя добавить в корзину" +"The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.","Система не может удостовериться в наличии необходимого количества (изменчиво). Товар добавлен в корзину для предварительного бронирования." +"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.","Отсутствует соединение к интернету. Однако вы можете оформить заказ. Мы уведомим Вас об отсутствии какого-либо из заказанных товаров позднее, так как не можем проверить это в данный момент." +"This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!","Данная возможность пока не реализована! Пожалуйста ознакомьтесь с нашей дорожной картой тут: https://github.com/DivanteLtd/vue-storefront/issues" +"Thumbnail","Thumbnail" +"Type what you are looking for...","Введите то что Вы ищите..." "Unhandled error, wrong response format!","Необработанная ошибка, неверный формат ответа!" -"Are you sure you would like to remove this item from the shopping cart?","Вы уверены, что хотите удалить этот товар из корзины?" +"You are logged in!","Вы авторизовались!" "You need to be logged in to see this page","Вы должны войти в систему, чтобы увидеть эту страницу" +"You submitted your review for moderation.","You submitted your review for moderation." +"You're logged out","Вы вышли из учетной записи" diff --git a/core/i18n/resource/i18n/zh-cn.csv b/core/i18n/resource/i18n/zh-cn.csv index b82419f6d..f60cf1b68 100644 --- a/core/i18n/resource/i18n/zh-cn.csv +++ b/core/i18n/resource/i18n/zh-cn.csv @@ -1,68 +1,73 @@ -"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!","部分订购的商品不可用!" -"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.","系统不确定库存量(不稳定)。 产品已添加到购物车中进行预订." -"This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!","此功能尚未实现! 请查看我们的开发路线图https://github.com/DivanteLtd/vue-storefront/issues!" -"The product is out of stock and cannot be added to the cart!","该商品缺货,无法添加到购物车!" -"Product has been added to the cart!","已成功将商品加入购物车!" -"Internal validation error. Please check if all required fields are filled in. Please contact us on {email}","内部验证错误。 请检查是否填写了所有必填字段。请通过 {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} !" +" is out of stock!"," 没有库存!" +"404 Page Not Found","404 出错了,网页未找到。" "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","主页" +"Add review","添加评论" +"Adding a review ...","Adding a review ..." +"Allow notification about the order","允许订阅有关订单的通知" +"Are you sure you would like to remove this item from the shopping cart?","您确定要从购物车中删除此商品吗?" "Checkout","注销" -"Subtotal incl. tax","含税小计" -"Grand total","合计" -"Field is required","必填字段" -"You're logged out","您已注销" "Compare Products","商品比较" -"404 Page Not Found","404 出错了,网页未找到。" +"Compare products","商品比较" +"Confirm your order","确认订单" +"Error refreshing user token. User is not authorized to access the resource","刷新用户令牌时出错。 用户无权访问该资源" "Error with response - bad content-type!","服务端响应错误 - 错误的 content-type!" -"Unhandled error, wrong response format!","未处理的错误,错误的响应格式!" -"not authorized","没有权限" -"Internal Application error while refreshing the tokens. Please clear the storage and refresh page.","刷新令牌时出现内部应用程序错误。 请清除浏览器缓存,刷新页面重试." -"Proceed to checkout","去结算" -"OK","确定" +"Extension developers would like to thank you for placing an order!","拓展开发市场人员感谢您下订单!" +"Field is required","必填字段" +"Grand total","合计" +"Home Page","主页" "In stock!","库存充足!" -"Please configure product custom options and fix the validation errors","请配置产品自定义选项并修复验证错误" -"Error refreshing user token. User is not authorized to access the resource","刷新用户令牌时出错。 用户无权访问该资源" +"Internal Application error while refreshing the tokens. Please clear the storage and refresh page.","刷新令牌时出现内部应用程序错误。 请清除浏览器缓存,刷新页面重试." +"Internal validation error. Please check if all required fields are filled in. Please contact us on {email}","内部验证错误。 请检查是否填写了所有必填字段。请通过 {email} 与我们联系" "Must be greater than 0","必须大于0" -"Please select the field which You like to sort by","请选择您要排序的字段" +"My Account","我的账户" +"Newsletter preferences have successfully been updated","时事通讯首选项已成功更新" "No available product variants","没有可用的产品" -"email","电子邮箱" -"password","密码" -"Confirm your order","确认订单" -"Please confirm order you placed when you was offline","请确认您离线时的订单" +"No products synchronized for this category. Please come back while online!","没有为此类别的商品。 请等商品上线后再来看看!" +"No such configuration for the product. Please do choose another combination of attributes.","没有这样的商品配置。 请选择其他属性组合." +"OK","确定" +"Only {maxQuantity} products of this type are available!","Only {maxQuantity} products of this type are available!" +"Out of stock!","缺库存!" "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!","拓展开发市场人员感谢您下订单!" -"most you may purchase","你最多可以买" -"have as many","有尽可能多的" -"Compare products","商品比较" -"Reviews","评论列表" +"Please configure product bundle options and fix the validation errors","请配置商品属性选项并修复验证错误" +"Please configure product custom options and fix the validation errors","请配置产品自定义选项并修复验证错误" +"Please confirm order you placed when you was offline","请确认您离线时的订单" +"Please fix the validation errors","请修复验证错误" +"Please select the field which You like to sort by","请选择您要排序的字段" +"Proceed to checkout","去结算" +"Processing order...","订单处理中..." +"Product has been added to the cart!","已成功将商品加入购物车!" +"Product price is unknown, product cannot be added to the cart!","商品价格未确定,无法添加到购物车!" +"Product quantity has been updated!","產品數量已更新!" +"Product {productName} has been added to the compare!","商品 {productName} 已经添加到比较列表中!" +"Product {productName} has been added to wishlist!","商品 {productName} 已添加到心愿单!" +"Product {productName} has been removed from compare!","商品 {productName} 已从比较列表中移除!" +"Product {productName} has been removed from wishlist!","已从心愿单中移除了商品 {productName} !" +"Registering the account ...","注册帐户 ..." +"Reset password feature does not work while offline!","离线状态时,不能重置密码!" "Review","评论" -"Add review","添加评论" +"Reviews","评论列表" +"Shopping cart is empty. Please add some products before entering Checkout","亲,购物车空空如也~, 快去选择一些你喜欢的商品吧." +"Some of the ordered products are not available!","部分订购的商品不可用!" +"Stock check in progress, please wait while available stock quantities are checked","正在确认库存数量,请稍候" +"Subtotal incl. tax","含税小计" "Summary","摘要", -"login","登录" -"to account","我的账户" -"Are you sure you would like to remove this item from the shopping cart?","您确定要从购物车中删除此商品吗?" +"The product is out of stock and cannot be added to the cart!","该商品缺货,无法添加到购物车!" "The product, category or CMS page is not available in Offline mode. Redirecting to Home.","在离线模式下,商品,类别或CMS页面不可用。 请返回主页." -"Please configure product bundle options and fix the validation errors","请配置商品属性选项并修复验证错误" -"Processing order...","订单处理中..." +"The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.","系统不确定库存量(不稳定)。 产品已添加到购物车中进行预订." +"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.","网络连接不上 我们现在无法确认库存。但是您仍然可以下订单, 如果订购的产品有问题,我们会通知您。" +"This feature is not implemented yet! Please take a look at https://github.com/DivanteLtd/vue-storefront/issues for our Roadmap!","此功能尚未实现! 请查看我们的开发路线图https://github.com/DivanteLtd/vue-storefront/issues!" +"Thumbnail","Thumbnail" +"Type what you are looking for...","请输入你想要查找的关键词..." +"Unhandled error, wrong response format!","未处理的错误,错误的响应格式!" +"You are logged in!","您已成功登录!" +"You are to pay for this order upon delivery.","您需要在收货时支付此订单." "You need to be logged in to see this page","您需要登录才能查看此页面" +"You submitted your review for moderation.","You submitted your review for moderation." +"You're logged out","您已注销" +"email","电子邮箱" +"have as many","有尽可能多的" +"login","登录" +"most you may purchase","你最多可以买" +"not authorized","没有权限" +"password","密码" +"to account","我的账户" diff --git a/core/i18n/scripts/translation.preprocessor.js b/core/i18n/scripts/translation.preprocessor.js index f30a8886d..7164d39ed 100644 --- a/core/i18n/scripts/translation.preprocessor.js +++ b/core/i18n/scripts/translation.preprocessor.js @@ -2,6 +2,7 @@ const fs = require('fs') const path = require('path') const dsvFormat = require('d3-dsv').dsvFormat const dsv = dsvFormat(',') +const { currentBuildLocales } = require('../helpers') /** * Converts an Array to an Object @@ -15,14 +16,18 @@ function convertToObject (array) { } module.exports = function (csvDirectories, config = null) { + const currentLocales = currentBuildLocales() + const fallbackLocale = 'en-US' let messages = {} let languages = [] + // get messages from CSV files csvDirectories.forEach(directory => { fs.readdirSync(directory).forEach(file => { const fullFileName = path.join(directory, file) 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) { @@ -34,26 +39,27 @@ module.exports = function (csvDirectories, config = null) { }) }) - languages.forEach((language) => { - if (!config || !config.i18n.bundleAllStoreviewLanguages || (config.i18n.bundleAllStoreviewLanguages && language === 'en-US')) { - console.debug(`Writing JSON file: ${language}.json`) - fs.writeFileSync(path.join(__dirname, '../resource/i18n', `${language}.json`), JSON.stringify(messages[language])) - } - }) + // create fallback + console.debug(`Writing JSON file fallback: ${fallbackLocale}.json`) + fs.writeFileSync(path.join(__dirname, '../resource/i18n', `${fallbackLocale}.json`), JSON.stringify(messages[fallbackLocale])) + // bundle all messages in one file if (config && config.i18n.bundleAllStoreviewLanguages) { - const bundledLanguages = { 'en-US': messages['en-US'] } // fallback locale + const bundledLanguages = { [fallbackLocale]: messages[fallbackLocale] } // fallback locale bundledLanguages[config.i18n.defaultLocale] = messages[config.i18n.defaultLocale] // default locale - Object.keys(config.storeViews).forEach((storeCode) => { - const store = config.storeViews[storeCode] - if (store.hasOwnProperty('storeCode')) { - if (!store.disabled && store.i18n) { - bundledLanguages[store.i18n.defaultLocale] = messages[store.i18n.defaultLocale] - } - } + currentLocales.forEach((locale) => { + bundledLanguages[locale] = messages[locale] }) + + console.debug(`Writing JSON file multistoreLanguages`) fs.writeFileSync(path.join(__dirname, '../resource/i18n', `multistoreLanguages.json`), JSON.stringify(bundledLanguages)) } else { + currentLocales.forEach((language) => { + if (language !== fallbackLocale) return // it's already loaded + const filePath = path.join(__dirname, '../resource/i18n', `${language}.json`) + console.debug(`Writing JSON file: ${language}.json`) + fs.writeFileSync(filePath, JSON.stringify(messages[language])) + }) fs.writeFileSync(path.join(__dirname, '../resource/i18n', `multistoreLanguages.json`), JSON.stringify({})) // fix for webpack compilation error in case of `bundleAllStoreviewLanguages` = `false` (#3188) } } diff --git a/core/lib/hooks.ts b/core/lib/hooks.ts new file mode 100644 index 000000000..9ac954cd4 --- /dev/null +++ b/core/lib/hooks.ts @@ -0,0 +1,63 @@ +/** + Listener hook just fires functions passed to hook function when executor is invoked. + e. g. We want to listen for onAppInit event in various places of the application. + Functions passed to this hook will be invoked only when executor function is executed. + Usually we want to use hook in app/modules and executor in core. + @return hook: a hook function to use in modules + @return executor: a function that will run all the collected hooks + */ +function createListenerHook () { + const functionsToRun: ((arg: T) => void)[] = [] + + function hook (fn: (arg?: T) => void) { + functionsToRun.push(fn) + } + + function executor (args: T = null): void { + functionsToRun.forEach(fn => fn(args)) + } + + return { + hook, + executor + } +} + +/** + Mutators work like listeners except they can modify passed value in hooks. + e.g we can apply the hook mutator to object order that is returned before placing order + now you can access and modify this value from hook returned by this function + @return hook: a hook function to use in modules + @return executor: a function that will apply all hooks on a given value + */ +function createMutatorHook () { + const mutators: ((arg: T) => R)[] = [] + + function hook (mutator: (arg: T) => R) { + mutators.push(mutator) + } + + function executor (rawOutput: T): T | R { + if (mutators.length > 0) { + let modifiedOutput: R = null + mutators.forEach(fn => { + modifiedOutput = fn(rawOutput) + }) + return modifiedOutput + } else { + return rawOutput + } + } + + return { + hook, + executor + } +} + +export { + createListenerHook, + createMutatorHook +} + +// TODO: Hooks for Client entry, replaceState (can be part of client entry), shopping cart loaded, user logged diff --git a/core/lib/module/index.ts b/core/lib/module/index.ts index ebb151da8..9ae5951e6 100644 --- a/core/lib/module/index.ts +++ b/core/lib/module/index.ts @@ -1,3 +1,4 @@ +// @deprecated from 2.0 import { Module } from 'vuex' import { RouteConfig, NavigationGuard } from 'vue-router' import Vue from 'vue' @@ -9,7 +10,6 @@ import { router } from '@vue-storefront/core/app' import { isServer } from '@vue-storefront/core/helpers' import { VSF, VueStorefrontModuleConfig } from './types' import { doesStoreAlreadyExists, mergeStores } from './helpers' -import { RouterManager } from '@vue-storefront/core/lib/router-manager' import config from 'config' const moduleExtendings: VueStorefrontModuleConfig[] = [] @@ -24,10 +24,6 @@ function registerModules (modules: VueStorefrontModule[], context): void { )() } -function isModuleRegistered (key: string): boolean { - return registeredModules.some(m => m.key === key) -} - function extendModule (moduleConfig: VueStorefrontModuleConfig) { moduleExtendings.push(moduleConfig) } @@ -53,7 +49,7 @@ class VueStorefrontModule { } private _extendModule (extendedConfig: VueStorefrontModuleConfig): void { - const mergedStore = { modules: [] }; + const mergedStore = { modules: [], plugin: null } const key = this._c.key const originalStore = this._c.store const extendedStore = extendedConfig.store @@ -61,6 +57,7 @@ class VueStorefrontModule { delete extendedConfig.store this._c = merge(this._c, extendedConfig) mergedStore.modules = mergeStores(originalStore, extendedStore) + mergedStore.plugin = extendedStore.plugin || originalStore.plugin || null this._c.store = mergedStore Logger.info('Module "' + key + '" has been succesfully extended.', 'module')() } @@ -76,6 +73,7 @@ class VueStorefrontModule { public register (): VueStorefrontModuleConfig | void { if (!this._isRegistered) { + Logger.warn('The module you are registering is using outdated API that will soon be depreciated. Please check https://docs.vuestorefront.io to learn more.', 'module', this._c.key)() let areStoresUnique = true const VSF: VSF = { Vue, @@ -134,6 +132,5 @@ export { extendModule, VueStorefrontModule, registerModules, - isModuleRegistered, createModule } diff --git a/core/lib/modules.ts b/core/lib/modules.ts new file mode 100644 index 000000000..e5b5e8001 --- /dev/null +++ b/core/lib/modules.ts @@ -0,0 +1,43 @@ +import { Store } from 'vuex' +import VueRouter from 'vue-router' +import Vue from 'vue' +import RootState from '@vue-storefront/core/types/RootState' + +export type StorefrontModule = ( + options: { + app: Vue, + store: Store, + router: VueRouter, + moduleConfig: any, + appConfig: any + } +) => void + +let refs: any = {} +let registeredModules: StorefrontModule[] = [] + +function injectReferences (app: any, store: Store, router: VueRouter, config: any): void { + refs.app = app + refs.store = store + refs.router = router + refs.config = config +} + +function registerModule (module: StorefrontModule, config?: any) { + if (!registeredModules.includes(module)) { + module({ + app: refs.app, + store: refs.store, + router: refs.router, + appConfig: refs.config, + moduleConfig: config + }) + registeredModules.push(module) + } +} + +function isModuleRegistered (name: string): boolean { + return registeredModules.some(m => m.name === name) +} + +export { refs, injectReferences, registerModule, isModuleRegistered } diff --git a/core/lib/multistore.ts b/core/lib/multistore.ts index dbf90e955..57e0e727c 100644 --- a/core/lib/multistore.ts +++ b/core/lib/multistore.ts @@ -1,52 +1,74 @@ import rootStore from '../store' import { loadLanguageAsync } from '@vue-storefront/i18n' import { initializeSyncTaskStorage } from './sync/task' +import { Logger } from '@vue-storefront/core/lib/logger' import Vue from 'vue' import queryString from 'query-string' -import { RouterManager } from '@vue-storefront/core/lib/router-manager' +import merge from 'lodash-es/merge' import VueRouter, { RouteConfig, RawLocation } from 'vue-router' import config from 'config' +import { coreHooksExecutors } from '@vue-storefront/core/hooks' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' import { LocalizedRoute, StoreView } from './types' import storeCodeFromRoute from './storeCodeFromRoute' +function getExtendedStoreviewConfig (storeView: StoreView): StoreView { + if (storeView.extend) { + const originalParent = storeView.extend + + if (!config.storeViews[originalParent]) { + Logger.error(`Storeview "${storeView.extend}" doesn't exist!`)() + } else { + storeView = merge( + {}, + getExtendedStoreviewConfig(config.storeViews[originalParent]), + storeView + ) + } + } + + return storeView +} + export function currentStoreView (): StoreView { // TODO: Change to getter all along our code return rootStore.state.storeView } export async function prepareStoreView (storeCode: string): Promise { - let storeView = { // current, default store + let storeView: 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 + storeCode: null, + storeId: config.defaultStoreCode && config.defaultStoreCode !== '' ? config.storeViews[config.defaultStoreCode].storeId : 1, + seo: Object.assign({}, config.seo) } - const storeViewHasChanged = !rootStore.state.storeView || rootStore.state.storeView.storeCode !== storeCode - if (storeCode) { // current store code - const currentStoreView = config.storeViews[storeCode] - if (currentStoreView) { - storeView = Object.assign({}, currentStoreView); - storeView.storeCode = storeCode - rootStore.state.user.current_storecode = storeCode - } else { - console.warn(`Not found 'storeView' matching the given 'storeCode': ${storeCode}`) - } + + if (config.storeViews.multistore === true) { + storeView.storeCode = storeCode || config.defaultStoreCode || '' } else { - storeView.storeCode = config.defaultStoreCode || '' - rootStore.state.user.current_storecode = config.defaultStoreCode || '' + storeView.storeCode = storeCode || '' } + + const storeViewHasChanged = !rootStore.state.storeView || rootStore.state.storeView.storeCode !== storeCode + + if (storeView.storeCode && config.storeViews.multistore === true && config.storeViews[storeView.storeCode]) { + storeView = merge(storeView, getExtendedStoreviewConfig(config.storeViews[storeView.storeCode])) + } + rootStore.state.user.current_storecode = storeView.storeCode + if (storeViewHasChanged) { + storeView = coreHooksExecutors.beforeStoreViewChanged(storeView) rootStore.state.storeView = storeView await loadLanguageAsync(storeView.i18n.defaultLocale) } - if (storeViewHasChanged || Vue.prototype.$db.currentStoreCode !== storeCode) { - if (typeof Vue.prototype.$db === 'undefined') { - Vue.prototype.$db = {} - } + if (storeViewHasChanged || StorageManager.currentStoreCode !== storeCode) { initializeSyncTaskStorage() - Vue.prototype.$db.currentStoreCode = storeView.storeCode + StorageManager.currentStoreCode = storeView.storeCode } + coreHooksExecutors.afterStoreViewChanged(storeView) + return storeView } @@ -60,11 +82,32 @@ export function removeStoreCodeFromRoute (matchedRouteOrUrl: LocalizedRoute | st } } +function removeURLQueryParameter (url, parameter) { + // prefer to use l.search if you have a location/link object + var urlparts = url.split('?'); + if (urlparts.length >= 2) { + var prefix = encodeURIComponent(parameter) + '='; + var pars = urlparts[1].split(/[&;]/g); + + // reverse iteration as may be destructive + for (var i = pars.length; i-- > 0;) { + // idiom for string.startsWith + if (pars[i].lastIndexOf(prefix, 0) !== -1) { + pars.splice(i, 1); + } + } + + return urlparts[0] + (pars.length > 0 ? '?' + pars.join('&') : ''); + } + return url; +} + export function adjustMultistoreApiUrl (url: string): string { - const storeView = currentStoreView() - if (storeView.storeCode) { + const { storeCode } = currentStoreView() + if (storeCode) { + url = removeURLQueryParameter(url, 'storeCode') const urlSep = (url.indexOf('?') > 0) ? '&' : '?' - url += urlSep + 'storeCode=' + storeView.storeCode + url += `${urlSep}storeCode=${storeCode}` } return url } @@ -98,7 +141,14 @@ export function localizedDispatcherRoute (routeObj: LocalizedRoute | string, sto return routeObj } -export function localizedRoute (routeObj: LocalizedRoute | string | RouteConfig | RawLocation, storeCode: string): any { +export function localizedDispatcherRouteName (routeName: string, storeCode: string, appendStoreCode: boolean = false): string { + if (appendStoreCode) { + return `${storeCode}-${routeName}` + } + return routeName +} + +export function localizedRoute (routeObj: LocalizedRoute | string | RouteConfig | RawLocation, storeCode: string = null): any { if (!storeCode) { storeCode = currentStoreView().storeCode } @@ -112,34 +162,59 @@ export function localizedRoute (routeObj: LocalizedRoute | string | RouteConfig } } - if (storeCode && routeObj && config.defaultStoreCode !== storeCode && config.storeViews[storeCode].appendStoreCode) { - if (typeof routeObj === 'object') { - if (routeObj.name) { - routeObj.name = storeCode + '-' + routeObj.name - } - - if (routeObj.path) { - routeObj.path = '/' + storeCode + '/' + (routeObj.path.startsWith('/') ? routeObj.path.slice(1) : routeObj.path) - } - } else { - return '/' + storeCode + routeObj + if (storeCode && config.defaultStoreCode !== storeCode && config.storeViews[storeCode] && config.storeViews[storeCode].appendStoreCode) { + if (typeof routeObj !== 'object') { + return localizedRoutePath(routeObj, storeCode) } + return localizedRouteConfig(routeObj as RouteConfig, storeCode) } return routeObj } -export function setupMultistoreRoutes (config, router: VueRouter, routes: RouteConfig[]): void { - const allStoreRoutes = [...routes] - if (config.storeViews.mapStoreUrlsFor.length > 0 && config.storeViews.multistore === true) { - for (const storeCode of config.storeViews.mapStoreUrlsFor) { - if (storeCode && (config.defaultStoreCode !== storeCode)) { - for (const route of routes) { - const localRoute = localizedRoute(Object.assign({}, route), storeCode) - allStoreRoutes.push(localRoute) - } - } - } +export function setupMultistoreRoutes (config, router: VueRouter, routes: RouteConfig[], priority: number = 0): void { + const allRoutes: RouteConfig[] = [] + const { storeCode, appendStoreCode } = currentStoreView() + if (storeCode && appendStoreCode) { + allRoutes.push(...routes.map(route => localizedRouteConfig(route, storeCode))) + } else { + allRoutes.push(...routes) } - RouterManager.addRoutes(allStoreRoutes, router) + router.addRoutes(allRoutes, true, priority) +} + +/** + * Returns transformed route config with language + * @param route - route config object + * @param storeCode - language prefix specified in global config + * @param isChildRoute - determines if route config is for child route + */ +export function localizedRouteConfig (route: RouteConfig, storeCode: string, isChildRoute: boolean = false): RouteConfig { + // note: we need shallow copy to prevent modifications in provided route object + const _route = {...route} + + if (_route.name && storeCode) { + _route.name = `${storeCode}-${_route.name}` + } + + if (_route.path && !isChildRoute) { + _route.path = localizedRoutePath(_route.path, storeCode) + } + + if (_route.children) { + _route.children = _route.children.map(childRoute => localizedRouteConfig(childRoute, storeCode, true)) + } + + return _route +} + +/** + * Returns route path with proper language prefix + * @param path - route path + * @param storeCode - language prefix specified in global config + */ +export function localizedRoutePath (path: string, storeCode: string): string { + const _path = path.startsWith('/') ? path.slice(1) : path + + return `/${storeCode}/${_path}` } diff --git a/core/lib/router-manager.ts b/core/lib/router-manager.ts index 87795f6b5..e20ad54c8 100644 --- a/core/lib/router-manager.ts +++ b/core/lib/router-manager.ts @@ -1,30 +1,66 @@ -import { router } from '@vue-storefront/core/app' -import VueRouter, { RouteConfig, Route } from 'vue-router' +import { baseRouter } from '@vue-storefront/core/app' +import { RouteConfig } from 'vue-router' const RouterManager = { _registeredRoutes: new Array(), + _routeQueue: new Array(), + _routeQueueFlushed: false, _routeLock: null, _routeDispatched: false, _callbacks: [], - addRoutes: function (routes: RouteConfig[], routerInstance: VueRouter = router): void { - const uniqueRoutes = routes.filter( - (route) => this._registeredRoutes.findIndex( - (registeredRoute) => registeredRoute.name === route.name && registeredRoute.path === route.path - ) === -1 - ) - if (uniqueRoutes.length > 0) { - this._registeredRoutes.push(...uniqueRoutes) - router.addRoutes(uniqueRoutes) + addRoutes: function (routes: RouteConfig[], useRouteQueue: boolean = false, priority: number = 0): void { + if (useRouteQueue && !this._routeQueueFlushed) { + this._routeQueue.push(...routes.map(route => { return { route: route, priority: priority } })) + } else { + const uniqueRoutes = routes.filter((route) => { + return this._registeredRoutes.findIndex(registeredRoute => registeredRoute.route.name === route.name && registeredRoute.route.path === route.path) < 0 + }) + if (uniqueRoutes.length > 0) { + this._registeredRoutes.push(...uniqueRoutes.map(route => { return { route: route, priority: priority } })) + baseRouter.addRoutes(uniqueRoutes) + } } }, + flushRouteQueue: function (): void { + if (!this._routeQueueFlushed) { + this.addRoutesByPriority(this._routeQueue) + this._routeQueueFlushed = true + this._routeQueue = [] + } + }, + addRoutesByPriority: function (routesData) { + const routesToAdd = [] + for (const routeData of routesData) { + let exisitingIndex = routesToAdd.findIndex(r => r.route.name === routeData.route.name && r.route.path === routeData.route.path) + if ((exisitingIndex >= 0) && (routesToAdd[exisitingIndex].priority < routeData.priority)) { // same priority doesn't override exisiting + routesToAdd.splice(exisitingIndex, 1) + exisitingIndex = -1 + } + if (exisitingIndex < 0) { + routesToAdd.push(routeData) + } + } + this._registeredRoutes.push(...routesToAdd) + baseRouter.addRoutes(routesToAdd.map(r => r.route)) + }, + isRouteAdded: function (addedRoutes: any[], route: RouteConfig) { + return addedRoutes.findIndex((addedRoute) => addedRoute.route.name === route.name && addedRoute.route.path === route.path) >= 0 + }, addDispatchCallback: function (callback: Function) { this._callbacks.push(callback) }, findByName: function (name: string): RouteConfig { - return this._registeredRoutes.find(r => r.name === name) + return this.findByProperty('name', name) }, findByPath: function (path: string): RouteConfig { - return this._registeredRoutes.find(r => r.path === path) + return this.findByProperty('path', path) + }, + findByProperty: function (property: string, value: string): RouteConfig { + const registeredRoute = this._registeredRoutes.find(r => r.route[property] === value) + if (registeredRoute) return registeredRoute.route + if (this._routeQueueFlushed) return null + const queuedRoute = this._routeQueue.find(queueItem => queueItem.route[property] === value) + return queuedRoute ? queuedRoute.route : null }, lockRoute: function () { let resolver diff --git a/core/lib/search.ts b/core/lib/search.ts index 8342ea7f3..761d99a8b 100644 --- a/core/lib/search.ts +++ b/core/lib/search.ts @@ -9,6 +9,7 @@ import { SearchResponse } from '@vue-storefront/core/types/search/SearchResponse import { Logger } from '@vue-storefront/core/lib/logger' import config from 'config' import { isServer } from '@vue-storefront/core/helpers' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' // TODO - use one from helpers instead export function isOnline (): boolean { @@ -27,7 +28,7 @@ export function isOnline (): boolean { * @param {Int} size page size * @return {Promise} */ -export const quickSearchByQuery = async ({ query, start = 0, size = 50, entityType = 'product', sort = '', storeCode = null, excludeFields = null, includeFields = null }): Promise => { +export const quickSearchByQuery = async ({ query = {}, start = 0, size = 50, entityType = 'product', sort = '', storeCode = null, excludeFields = null, includeFields = null } = {}): Promise => { const searchAdapter = await getSearchAdapter() if (size <= 0) size = 50 if (start < 0) start = 0 @@ -52,7 +53,7 @@ export const quickSearchByQuery = async ({ query, start = 0, size = 50, entityTy Request.groupId = rootStore.state.user.groupId } - const cache = Vue.prototype.$db.elasticCacheCollection // switch to appcache? + const cache = StorageManager.get('elasticCache') // switch to appcache? let servedFromCache = false const cacheKey = sha3_224(JSON.stringify(Request)) const benchmarkTime = new Date() @@ -77,7 +78,7 @@ export const quickSearchByQuery = async ({ query, start = 0, size = 50, entityTy delete Request.groupId } - if (config.usePriceTiers && rootStore.state.user.groupToken) { + if (rootStore.state.user.groupToken) { Request.groupToken = rootStore.state.user.groupToken } diff --git a/core/lib/search/adapter/api/elasticsearch/multimatch.js b/core/lib/search/adapter/api/elasticsearch/multimatch.js index 64a71339e..1abd481c3 100644 --- a/core/lib/search/adapter/api/elasticsearch/multimatch.js +++ b/core/lib/search/adapter/api/elasticsearch/multimatch.js @@ -2,12 +2,10 @@ import config from 'config' function getConfig (queryText) { let scoringConfig = config.elasticsearch.hasOwnProperty('searchScoring') ? config.elasticsearch.searchScoring : {} - let minimumShouldMatch = '' + 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 = scoringConfig.hasOwnProperty('minimum_should_match') ? scoringConfig.minimum_should_match + '25' : '75%25' - } else { - minimumShouldMatch = scoringConfig.hasOwnProperty('minimum_should_match') ? scoringConfig.minimum_should_match : '75%' + minimumShouldMatch = encodeURIComponent(minimumShouldMatch) } // Create config for multi match query let multiMatchConfig = { diff --git a/core/lib/search/adapter/api/elasticsearchQuery.js b/core/lib/search/adapter/api/elasticsearchQuery.js index 717ed9e3a..3ba4e1081 100644 --- a/core/lib/search/adapter/api/elasticsearchQuery.js +++ b/core/lib/search/adapter/api/elasticsearchQuery.js @@ -46,7 +46,7 @@ export async function prepareElasticsearchQueryBody (searchQuery) { let rangeAttribute = catalogfilter.attribute // filter by product fiunal price if (rangeAttribute === 'price') { - rangeAttribute = 'final_price' + rangeAttribute = config.products.priceFilterKey } // process range filters filterQr = filterQr.andFilter('range', rangeAttribute, catalogfilter.value) @@ -68,7 +68,7 @@ export async function prepareElasticsearchQueryBody (searchQuery) { } if (hasCatalogFilters) { - query = query.orFilter('bool', (b) => attrFilterBuilder(b)) + 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 } } @@ -113,7 +113,6 @@ export async function prepareElasticsearchQueryBody (searchQuery) { } } const queryBody = query.build() - if (searchQuery.suggest) { queryBody.suggest = searchQuery.suggest } diff --git a/core/lib/search/adapter/graphql/gqlQuery.js b/core/lib/search/adapter/graphql/gqlQuery.js index 0bb90a660..80595a9f7 100644 --- a/core/lib/search/adapter/graphql/gqlQuery.js +++ b/core/lib/search/adapter/graphql/gqlQuery.js @@ -45,7 +45,15 @@ export function prepareQueryVars (Request) { if (Request.sort !== '') { const sortParse = Request.sort.split(':') - queryVariables.sort[sortParse[0]] = sortParse[1].toUpperCase() + if (sortParse[1] !== undefined) { + queryVariables.sort[sortParse[0]] = sortParse[1].toUpperCase() + } else { + if (sortParse[0] === '_score') { + queryVariables.sort[sortParse[0]] = 'DESC' + } else { + queryVariables.sort[sortParse[0]] = 'ASC' + } + } } queryVariables.pageSize = Request.size diff --git a/core/lib/search/adapter/graphql/queries/cmsBlock.gql b/core/lib/search/adapter/graphql/queries/cmsBlock.gql index 74f5b6531..e9cdb4921 100644 --- a/core/lib/search/adapter/graphql/queries/cmsBlock.gql +++ b/core/lib/search/adapter/graphql/queries/cmsBlock.gql @@ -5,9 +5,10 @@ query cmsBlocks ($filter: CmsInput) { { items { title + id identifier content creation_time } } -} \ No newline at end of file +} diff --git a/core/lib/search/adapter/searchAdapterFactory.js b/core/lib/search/adapter/searchAdapterFactory.js index fd519d5e7..02f1380df 100644 --- a/core/lib/search/adapter/searchAdapterFactory.js +++ b/core/lib/search/adapter/searchAdapterFactory.js @@ -1,5 +1,5 @@ import { server } from 'config' - +import { Logger } from '@vue-storefront/core/lib/logger' let instances = {} const isImplementingSearchAdapterInterface = (obj) => { @@ -7,7 +7,22 @@ const isImplementingSearchAdapterInterface = (obj) => { } export const getSearchAdapter = async (adapterName = server.api) => { - const SearchAdapterModule = await import(/* webpackChunkName: "vsf-search-adapter-" */ `./${adapterName}/searchAdapter`) + let SearchAdapterModule + + try { + SearchAdapterModule = await import(/* webpackChunkName: "vsf-search-adapter-" */ `src/search/adapter/${adapterName}/searchAdapter`) + } catch {} + + if (!SearchAdapterModule) { + try { + SearchAdapterModule = await import(/* webpackChunkName: "vsf-search-adapter-" */ `./${adapterName}/searchAdapter`) + } catch {} + } + + if (!SearchAdapterModule) { + throw new Error('Search adapter module was not found in `serc/search/adapter` neither in the `core/lib/search/addapter` folders') + } + const SearchAdapter = SearchAdapterModule.SearchAdapter if (!SearchAdapter) { diff --git a/core/lib/search/adapter/test/unit/searchAdapterFactory.spec.ts b/core/lib/search/adapter/test/unit/searchAdapterFactory.spec.ts index 6c15b5993..749856d71 100644 --- a/core/lib/search/adapter/test/unit/searchAdapterFactory.spec.ts +++ b/core/lib/search/adapter/test/unit/searchAdapterFactory.spec.ts @@ -3,6 +3,14 @@ import {getSearchAdapter} from '@vue-storefront/core/lib/search/adapter/searchAd jest.mock('config', () => { return {server: {api: 'api'}}; }); +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + log: jest.fn(() => () => {}), + debug: jest.fn(() => () => {}), + warn: jest.fn(() => () => {}), + error: jest.fn(() => () => {}) + } +})); const mockSearchAdapterModule = { SearchAdapter: jest.fn().mockImplementation(() => { diff --git a/core/lib/storage-manager.ts b/core/lib/storage-manager.ts new file mode 100644 index 000000000..7619f81b1 --- /dev/null +++ b/core/lib/storage-manager.ts @@ -0,0 +1,80 @@ +import { Logger } from '@vue-storefront/core/lib/logger' +import * as localForage from 'localforage' +import UniversalStorage from '@vue-storefront/core/lib/store/storage' +import { currentStoreView } from '@vue-storefront/core/lib/multistore' +import config from 'config' + +function _prepareCacheStorage (key, localized = !config.storeViews.commonCache, storageQuota = 0) { + const storeView = currentStoreView() + const dbNamePrefix = storeView && storeView.storeCode ? storeView.storeCode + '-' : '' + const cacheDriver = config.localForage && config.localForage.defaultDrivers[key] + ? config.localForage.defaultDrivers[key] + : 'LOCALSTORAGE' + + return new UniversalStorage(localForage.createInstance({ + name: localized ? `${dbNamePrefix}shop` : 'shop', + storeName: key, + driver: localForage[cacheDriver] + }), true, storageQuota) +} + +const StorageManager = { + currentStoreCode: '', + storageMap: {}, + /** + * Register the cache storage index that can be later accessed and modified - this is required prior to accessing the collection + * @param collectionName name of the cache collection to create + * @param isLocalized if set to `false` data will be shared between storeViews (default `true`) + * @param storageQuota max size of storage, 0 if unlimited (default `0`) + */ + init: function (collectionName: string, isLocalized = !config.storeViews.commonCache, storageQuota = 0) { + this.storageMap[collectionName] = _prepareCacheStorage(collectionName, isLocalized, storageQuota) + return this.storageMap[collectionName] + }, + /** + * Override or register the cache storage - this is required prior to accessing the collection + * @param collectionName { string} string name of the cache collection to register + * @param item UniversalStorage driver + */ + set: function (collectionName: string, collectionInstance: UniversalStorage): UniversalStorage { + this.storageMap[collectionName] = collectionInstance + return collectionInstance + }, + /** + * Check if the specified collection is already registered + * @param collectionName string collection name to check + */ + exists (collectionName): boolean { + return !!this.storageMap[collectionName] + }, + /** + * Returns the UniversalStorage driver for specific key. + * If it doesnt exist it creates it with defaults for `init` + * @returns UniversalStorage + */ + get: function (collectionName): UniversalStorage { + if (!this.exists(collectionName)) { + Logger.warn('Called cache collection ' + collectionName + ' does not exist. Initializing.', 'cache') + return this.set(collectionName, initCacheStorage(collectionName, true)) // eslint-disable-line @typescript-eslint/no-use-before-define + } else { + return this.storageMap[collectionName] + } + } +} + +/** + * @deprecated to be removed in 2.0 in favor to `StorageManager` + * */ +function initCacheStorage (key, localised = true, registerStorgeManager = true) { + if (registerStorgeManager) { + if (!StorageManager.exists(key)) { + return StorageManager.set(key, _prepareCacheStorage(key, localised)) + } else { + return StorageManager.get(key) + } + } else { + return _prepareCacheStorage(key, localised) + } +} + +export { StorageManager, initCacheStorage } diff --git a/core/store/lib/entities.ts b/core/lib/store/entities.ts similarity index 100% rename from core/store/lib/entities.ts rename to core/lib/store/entities.ts diff --git a/core/store/lib/filters.ts b/core/lib/store/filters.ts similarity index 100% rename from core/store/lib/filters.ts rename to core/lib/store/filters.ts diff --git a/core/store/lib/storage.ts b/core/lib/store/storage.ts similarity index 96% rename from core/store/lib/storage.ts rename to core/lib/store/storage.ts index 039dbad24..042ebb0c2 100644 --- a/core/store/lib/storage.ts +++ b/core/lib/store/storage.ts @@ -1,4 +1,3 @@ -import Vue from 'vue' import * as localForage from 'localforage' import { Logger } from '@vue-storefront/core/lib/logger' import { isServer } from '@vue-storefront/core/helpers' @@ -9,6 +8,8 @@ const CACHE_TIMEOUT_ITERATE = 2000 const DISABLE_PERSISTANCE_AFTER = 1 const DISABLE_PERSISTANCE_AFTER_SAVE = 30 +const _globalCache = {} + function roughSizeOfObject (object) { const objectList = [] const stack = [ object ] @@ -100,16 +101,13 @@ class LocalForageCacheDriver { if (isServer) { this._localCache = {} } else { - if (typeof Vue.prototype.$localCache === 'undefined') { - Vue.prototype.$localCache = {} - } - if (typeof Vue.prototype.$localCache[dbName] === 'undefined') { - Vue.prototype.$localCache[dbName] = {} + if (typeof _globalCache[dbName] === 'undefined') { + _globalCache[dbName] = {} } - if (typeof Vue.prototype.$localCache[dbName][collectionName] === 'undefined') { - Vue.prototype.$localCache[dbName][collectionName] = {} + if (typeof _globalCache[dbName][collectionName] === 'undefined') { + _globalCache[dbName][collectionName] = {} } - this._localCache = Vue.prototype.$localCache[dbName][collectionName] + this._localCache = _globalCache[dbName][collectionName] } this._collectionName = collectionName this._dbName = dbName @@ -209,7 +207,6 @@ class LocalForageCacheDriver { Logger.error(err)() isResolved = true })) - clearTimeout(this._cacheTimeouts.getItem) this._cacheTimeouts.getItem = setTimeout(() => { if (!isResolved) { // this is cache time out check diff --git a/core/lib/sync/helpers/index.ts b/core/lib/sync/helpers/index.ts new file mode 100644 index 000000000..76bac21d1 --- /dev/null +++ b/core/lib/sync/helpers/index.ts @@ -0,0 +1,21 @@ +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 +} diff --git a/core/lib/sync/index.ts b/core/lib/sync/index.ts index 5c272e48d..7f57ef691 100644 --- a/core/lib/sync/index.ts +++ b/core/lib/sync/index.ts @@ -6,16 +6,18 @@ import { execute as taskExecute, _prepareTask } from './task' import { isServer } from '@vue-storefront/core/helpers' import config from 'config' import Task from '@vue-storefront/core/lib/sync/types/Task' +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' /** Syncs given task. If user is offline requiest will be sent to the server after restored connection */ async function queue (task) { - const tasksCollection = Vue.prototype.$db.syncTaskCollection + const tasksCollection = StorageManager.get('syncTasks') task = _prepareTask(task) Logger.info('Sync task queued ' + task.url, 'sync', { task })() return new Promise((resolve, reject) => { tasksCollection.setItem(task.task_id.toString(), task, (err, resp) => { if (err) Logger.error(err, 'sync')() - Vue.prototype.$bus.$emit('sync/PROCESS_QUEUE', { config: config }) // process checkout queue + EventBus.$emit('sync/PROCESS_QUEUE', { config: config }) // process checkout queue resolve(task) }, config.syncTasks.disablePersistentTaskQueue).catch((reason) => { Logger.error(reason, 'sync')() // it doesn't work on SSR @@ -48,7 +50,7 @@ async function execute (task): Promise { // not offline task /** Clear sync tasks that were not transmitted yet */ function clearNotTransmited () { - const syncTaskCollection = Vue.prototype.$db.syncTaskCollection + const syncTaskCollection = StorageManager.get('syncTasks') syncTaskCollection.iterate((task, id, iterationNumber) => { if (!task.transmited) { syncTaskCollection.removeItem(id) diff --git a/core/lib/sync/task.ts b/core/lib/sync/task.ts index 1a3ace55c..7fe74eb26 100644 --- a/core/lib/sync/task.ts +++ b/core/lib/sync/task.ts @@ -1,23 +1,20 @@ -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' import { adjustMultistoreApiUrl, currentStoreView } from '@vue-storefront/core/lib/multistore' +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' import Task from '@vue-storefront/core/lib/sync/types/Task' import { Logger } from '@vue-storefront/core/lib/logger' import { TaskQueue } from '@vue-storefront/core/lib/sync' -import * as entities from '@vue-storefront/core/store/lib/entities' -import UniversalStorage from '@vue-storefront/core/store/lib/storage' +import * as entities from '@vue-storefront/core/lib/store/entities' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' 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' - -const AUTO_REFRESH_MAX_ATTEMPTS = 20 +import { hasResponseError, getResponseMessage } from '@vue-storefront/core/lib/sync/helpers' export function _prepareTask (task) { const taskId = entities.uniqueEntityId(task) // timestamp as a order id is not the best we can do but it's enough @@ -33,7 +30,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 +39,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) { @@ -75,7 +72,7 @@ function _internalExecute (resolve, reject, task: Task, currentToken, currentCar if (jsonResponse) { const responseCode = parseInt(jsonResponse.code) 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 @@ -84,12 +81,12 @@ function _internalExecute (resolve, reject, task: Task, currentToken, currentCar if (config.users.autoRefreshTokens) { if (!rootStore.state.userTokenInvalidateLock) { rootStore.state.userTokenInvalidateLock++ - if (rootStore.state.userTokenInvalidateAttemptsCount >= AUTO_REFRESH_MAX_ATTEMPTS) { + if (rootStore.state.userTokenInvalidateAttemptsCount >= config.queues.maxNetworkTaskAttempts) { Logger.error('Internal Application error while refreshing the tokens. Please clear the storage and refresh page.', 'sync')() rootStore.state.userTokenInvalidateLock = -1 rootStore.dispatch('user/logout', { silent: true }) TaskQueue.clearNotTransmited() - Vue.prototype.$bus.$emit('modal-show', 'modal-signup') + EventBus.$emit('modal-show', 'modal-signup') rootStore.dispatch('notification/spawnNotification', { type: 'error', message: i18n.t('Internal Application error while refreshing the tokens. Please clear the storage and refresh page.'), @@ -99,41 +96,39 @@ function _internalExecute (resolve, reject, task: Task, currentToken, currentCar } else { Logger.info('Invalidation process in progress (autoRefreshTokens is set to true)' + rootStore.state.userTokenInvalidateAttemptsCount + rootStore.state.userTokenInvalidateLock, 'sync')() rootStore.state.userTokenInvalidateAttemptsCount++ - rootStore.dispatch('user/refresh').then((resp) => { - if (resp.code === 200) { + rootStore.dispatch('user/refresh').then((token) => { + if (token) { rootStore.state.userTokenInvalidateLock = 0 - rootStore.state.userTokenInvalidated = resp.result - Logger.info('User token refreshed successfully' + resp.result, 'sync')() + rootStore.state.userTokenInvalidated = token + Logger.info('User token refreshed successfully' + token, 'sync')() } else { rootStore.state.userTokenInvalidateLock = -1 rootStore.dispatch('user/logout', { silent: true }) - Vue.prototype.$bus.$emit('modal-show', 'modal-signup') + EventBus.$emit('modal-show', 'modal-signup') TaskQueue.clearNotTransmited() - Logger.error('Error refreshing user token' + resp.result, 'sync')() + Logger.error('Error refreshing user token' + token, 'sync')() } }).catch((excp) => { rootStore.state.userTokenInvalidateLock = -1 rootStore.dispatch('user/logout', { silent: true }) - Vue.prototype.$bus.$emit('modal-show', 'modal-signup') + EventBus.$emit('modal-show', 'modal-signup') TaskQueue.clearNotTransmited() Logger.error('Error refreshing user token' + excp, 'sync')() }) } } - if (rootStore.state.userTokenInvalidateAttemptsCount <= AUTO_REFRESH_MAX_ATTEMPTS) _internalExecute(resolve, reject, task, currentToken, currentCartId) // retry + if (rootStore.state.userTokenInvalidateAttemptsCount <= config.queues.maxNetworkTaskAttempts) _internalExecute(resolve, reject, task, currentToken, currentCartId) // retry } else { Logger.info('Invalidation process is disabled (autoRefreshTokens is set to false)', 'sync')() rootStore.dispatch('user/logout', { silent: true }) - Vue.prototype.$bus.$emit('modal-show', 'modal-signup') + EventBus.$emit('modal-show', 'modal-signup') } } - 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') } }) } @@ -146,12 +141,13 @@ function _internalExecute (resolve, reject, task: Task, currentToken, currentCar task.resultCode = jsonResponse.code task.code = jsonResponse.code // backward compatibility to fetch() task.acknowledged = false + task.meta = jsonResponse.meta if (task.callback_event) { if (task.callback_event.startsWith('store:')) { rootStore.dispatch(task.callback_event.split(':')[1], task) } else { - Vue.prototype.$bus.$emit(task.callback_event, task) + EventBus.$emit(task.callback_event, task) } } if (!rootStore.state.userTokenInvalidateLock) { // in case we're revalidaing the token - user must wait for it @@ -180,19 +176,15 @@ export function initializeSyncTaskStorage () { const storeView = currentStoreView() const dbNamePrefix = storeView.storeCode ? storeView.storeCode + '-' : '' - Vue.prototype.$db.syncTaskCollection = new UniversalStorage(localForage.createInstance({ - name: dbNamePrefix + 'shop', - storeName: 'syncTasks', - driver: localForage[config.localForage.defaultDrivers['syncTasks']] - })) + StorageManager.init('syncTasks') } export function registerSyncTaskProcessor () { const mutex = {} - Vue.prototype.$bus.$on('sync/PROCESS_QUEUE', async data => { + EventBus.$on('sync/PROCESS_QUEUE', async data => { if (onlineHelper.isOnline) { // event.data.config - configuration, endpoints etc - const syncTaskCollection = Vue.prototype.$db.syncTaskCollection + const syncTaskCollection = StorageManager.get('syncTasks') const currentUserToken = rootStore.getters['user/getUserToken'] const currentCartToken = rootStore.getters['cart/getCartToken'] diff --git a/core/lib/sync/types/Task.ts b/core/lib/sync/types/Task.ts index dc359a727..1f723160b 100644 --- a/core/lib/sync/types/Task.ts +++ b/core/lib/sync/types/Task.ts @@ -10,5 +10,6 @@ export default interface Task { transmited: boolean, transmited_at: Date, url: string, - is_result_cacheable?: boolean + is_result_cacheable?: boolean, + meta: any } diff --git a/core/lib/test/unit/multistore.spec.ts b/core/lib/test/unit/multistore.spec.ts index 0ddf62be1..91ce20566 100644 --- a/core/lib/test/unit/multistore.spec.ts +++ b/core/lib/test/unit/multistore.spec.ts @@ -1,18 +1,44 @@ -import storeCodeFromRoute from '../../storeCodeFromRoute' +import storeCodeFromRoute from '@vue-storefront/core/lib/storeCodeFromRoute' +import { LocalizedRoute } from '@vue-storefront/core/lib/types' +import { + prepareStoreView, + adjustMultistoreApiUrl, + localizedDispatcherRoute, + setupMultistoreRoutes, + localizedRoutePath, + localizedRouteConfig +} from '@vue-storefront/core/lib/multistore' import config from 'config' +import rootStore from '@vue-storefront/core/store'; +import { router } from '@vue-storefront/core/app'; +import { RouteConfig } from 'vue-router' + +jest.mock('@vue-storefront/core/app', () => ({ + createApp: jest.fn(), + router: { + addRoutes: jest.fn() + } +})) jest.mock('../../../store', () => ({})) jest.mock('@vue-storefront/i18n', () => ({loadLanguageAsync: jest.fn()})) jest.mock('../../sync/task', () => ({initializeSyncTaskStorage: jest.fn()})) -jest.mock('query-string', () => jest.fn()) -jest.mock('@vue-storefront/core/lib/router-manager', () => ({ - RouterManager: {} +jest.mock('@vue-storefront/core/hooks', () => ({ coreHooksExecutors: { + beforeStoreViewChanged: jest.fn(args => args), + afterStoreViewChanged: jest.fn(args => args) +}})) +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: {} })) jest.mock('config', () => ({})) describe('Multistore', () => { beforeEach(() => { - jest.clearAllMocks() + jest.clearAllMocks(); + (rootStore as any).state = {}; Object.keys(config).forEach((key) => { delete config[key]; }); + rootStore.state.storeView = { + appendStoreCode: true + } }) describe('storeCodeFromRoute', () => { @@ -122,4 +148,590 @@ describe('Multistore', () => { expect(storeCodeFromRoute('')).toBe('') }) }) + + describe('prepareStoreView', () => { + it('returns default storeView given no storecode', async () => { + rootStore.state.storeView = {} + rootStore.state.user = {} + + config.storeViews = { + multistore: false + } + + config.tax = { + defaultCountry: 'US' + } + + config.i18n = { + defaultLocale: 'en-US', + fullCountryName: 'United States', + fullLanguageName: 'English' + } + + config.elasticsearch = { + index: 'vue_storefront_catalog' + } + config.defaultStoreCode = '' + + config.seo = { + defaultTitle: 'Vue Storefront' + } + + expect(await prepareStoreView(null)).toStrictEqual({ + tax: { + defaultCountry: 'US' + }, + i18n: { + defaultLocale: 'en-US', + fullCountryName: 'United States', + fullLanguageName: 'English' + }, + seo: { + defaultTitle: 'Vue Storefront' + }, + elasticsearch: { + index: 'vue_storefront_catalog' + }, + storeId: 1, + storeCode: '' + }) + }) + + it('returns default storeView without setting defaultStoreCode when multistore mode is disabled', async () => { + rootStore.state.storeView = {} + rootStore.state.user = {} + + config.storeViews = { + multistore: false, + de: { + storeId: 4 + } + } + + config.tax = { + defaultCountry: 'US' + } + + config.i18n = { + defaultLocale: 'en-US', + fullCountryName: 'United States', + fullLanguageName: 'English' + } + + config.elasticsearch = { + index: 'vue_storefront_catalog' + } + config.defaultStoreCode = 'de' + + config.seo = { + defaultTitle: 'Vue Storefront' + } + + expect(await prepareStoreView(null)).toStrictEqual({ + tax: { + defaultCountry: 'US' + }, + i18n: { + defaultLocale: 'en-US', + fullCountryName: 'United States', + fullLanguageName: 'English' + }, + seo: { + defaultTitle: 'Vue Storefront' + }, + elasticsearch: { + index: 'vue_storefront_catalog' + }, + storeId: 4, + storeCode: '' + }) + }) + + it('returns default storeView with defaultStoreCode set when multistore mode is enabled', async () => { + rootStore.state.storeView = {} + rootStore.state.user = {} + + config.storeViews = { + multistore: true, + de: { + storeId: 4 + } + } + + config.tax = { + defaultCountry: 'US' + } + + config.i18n = { + defaultLocale: 'en-US', + fullCountryName: 'United States', + fullLanguageName: 'English' + } + + config.seo = { + defaultTitle: 'Vue Storefront' + } + + config.elasticsearch = { + index: 'vue_storefront_catalog' + } + config.defaultStoreCode = 'de' + + expect(await prepareStoreView(null)).toStrictEqual({ + tax: { + defaultCountry: 'US' + }, + i18n: { + defaultLocale: 'en-US', + fullCountryName: 'United States', + fullLanguageName: 'English' + }, + seo: { + defaultTitle: 'Vue Storefront' + }, + elasticsearch: { + index: 'vue_storefront_catalog' + }, + storeId: 4, + storeCode: 'de' + }) + }) + + it('returns storeView overwritting default store config values when multistore mode is enabled', async () => { + rootStore.state.storeView = {} + rootStore.state.user = {} + + config.storeViews = { + multistore: true, + de: { + storeCode: 'de', + storeId: 3, + name: 'German Store', + elasticsearch: { + index: 'vue_storefront_catalog_de' + }, + tax: { + defaultCountry: 'DE' + }, + i18n: { + fullCountryName: 'Germany', + fullLanguageName: 'German', + defaultLocale: 'de-DE' + }, + seo: { + defaultTitle: 'Vue Storefront' + } + } + } + + config.tax = { + defaultCountry: 'US' + } + + config.i18n = { + defaultLocale: 'en-US', + fullCountryName: 'United States', + fullLanguageName: 'English' + } + + config.seo = { + defaultTitle: 'Vue Storefront' + } + + config.elasticsearch = { + index: 'vue_storefront_catalog' + } + config.defaultStoreCode = 'de' + + expect(await prepareStoreView(null)).toStrictEqual({ + tax: { + defaultCountry: 'DE' + }, + i18n: { + fullCountryName: 'Germany', + fullLanguageName: 'German', + defaultLocale: 'de-DE' + }, + seo: { + defaultTitle: 'Vue Storefront' + }, + elasticsearch: { + index: 'vue_storefront_catalog_de' + }, + storeId: 3, + name: 'German Store', + storeCode: 'de' + }) + }) + + it('returns storeView extending other storeView in multistore mode', async () => { + rootStore.state.storeView = {} + rootStore.state.user = {} + + config.storeViews = { + multistore: true, + de: { + storeCode: 'de', + storeId: 3, + name: 'German Store', + elasticsearch: { + index: 'vue_storefront_catalog_de' + }, + tax: { + defaultCountry: 'DE' + }, + i18n: { + fullCountryName: 'Germany', + fullLanguageName: 'German', + defaultLocale: 'de-DE' + }, + seo: { + defaultTitle: 'Vue Storefront' + } + }, + it: { + extend: 'de', + storeCode: 'it', + storeId: 4, + name: 'Italian Store', + elasticsearch: { + index: 'vue_storefront_catalog_it' + }, + tax: { + defaultCountry: 'IT' + }, + i18n: { + fullCountryName: 'Italy', + fullLanguageName: 'Italian', + defaultLocale: 'it-IT' + }, + seo: { + defaultTitle: 'Vue Storefront' + } + } + } + + config.tax = { + defaultCountry: 'US' + } + + config.i18n = { + defaultLocale: 'en-US', + fullCountryName: 'United States', + fullLanguageName: 'English' + } + + config.seo = { + defaultTitle: 'Vue Storefront' + } + + config.elasticsearch = { + index: 'vue_storefront_catalog' + } + config.defaultStoreCode = 'it' + + expect(await prepareStoreView(null)).toStrictEqual({ + tax: { + defaultCountry: 'IT' + }, + i18n: { + fullCountryName: 'Italy', + fullLanguageName: 'Italian', + defaultLocale: 'it-IT' + }, + seo: { + defaultTitle: 'Vue Storefront' + }, + elasticsearch: { + index: 'vue_storefront_catalog_it' + }, + storeId: 4, + extend: 'de', + name: 'Italian Store', + storeCode: 'it' + }) + }) + }) + + describe('adjustMultistoreApiUrl', () => { + it('returns URL /test without storeCode as parameter', () => { + rootStore.state.storeView = { + storeCode: null + } + + expect(adjustMultistoreApiUrl('/test')).toStrictEqual('/test') + }) + + it('returns URL /test with storeCode de as parameter', () => { + rootStore.state.storeView = { + storeCode: 'de' + } + + expect(adjustMultistoreApiUrl('/test')).toStrictEqual('/test?storeCode=de') + }) + + it('returns URL /test?a=b with storeCode de as parameter and current parameters from the URL', () => { + rootStore.state.storeView = { + storeCode: 'de' + } + + expect(adjustMultistoreApiUrl('/test?a=b')).toStrictEqual('/test?a=b&storeCode=de') + }) + + it('returns URL /test?a=b&storeCode=de with added storeCode at as parameter and removes previous storeCode parameter', () => { + rootStore.state.storeView = { + storeCode: 'at' + } + + expect(adjustMultistoreApiUrl('/test?a=b&storeCode=de')).toStrictEqual('/test?a=b&storeCode=at') + }) + + it('returns URL /test?storeCode=de with changed storeCode at as parameter', () => { + rootStore.state.storeView = { + storeCode: 'at' + } + + expect(adjustMultistoreApiUrl('/test?storeCode=de')).toStrictEqual('/test?storeCode=at') + }) + + it('returns URL /test?storeCode=de with changed storeCode at as parameter', () => { + rootStore.state.storeView = { + storeCode: 'at' + } + + expect(adjustMultistoreApiUrl('/test?storeCode=de&storeCode=de')).toStrictEqual('/test?storeCode=at') + }) + }) + + describe('localizedDispatcherRoute', () => { + it('URL /test stays the same', () => { + config.storeViews = {} + + expect(localizedDispatcherRoute('/test', 'de')).toStrictEqual('/test') + }) + + it('URL /test starts with /de', () => { + config.storeViews = { + de: { + appendStoreCode: true + } + } + + expect(localizedDispatcherRoute('/test', 'de')).toStrictEqual('/de/test') + }) + + it('URL /test?a=b&b=a stays the same', () => { + config.storeViews = {} + + expect(localizedDispatcherRoute('/test?a=b&b=a', 'de')).toStrictEqual('/test?a=b&b=a') + }) + + it('URL /test?a=b&b=a starts with /de', () => { + config.storeViews = { + de: { + appendStoreCode: true + } + } + + expect(localizedDispatcherRoute('/test?a=b&b=a', 'de')).toStrictEqual('/de/test?a=b&b=a') + }) + + it('URL with LocalizedRoute object with fullPath test gets prefixed with /de', () => { + config.storeViews = {} + + const LocalizedRoute: LocalizedRoute = { + fullPath: 'test' + } + + expect(localizedDispatcherRoute(LocalizedRoute, 'de')).toStrictEqual('/test') + }) + + it('URL with LocalizedRoute object with fullPath and parameter test stays the same', () => { + config.storeViews = {} + + const LocalizedRoute: LocalizedRoute = { + fullPath: 'test', + params: { + a: 'b', + b: 'a' + } + } + + expect(localizedDispatcherRoute(LocalizedRoute, 'de')).toStrictEqual('/test?a=b&b=a') + }) + + it('URL with LocalizedRoute object with fullPath test gets prefixed with /de', () => { + config.storeViews = { + de: { + appendStoreCode: true + } + } + + const LocalizedRoute: LocalizedRoute = { + fullPath: 'test' + } + + expect(localizedDispatcherRoute(LocalizedRoute, 'de')).toStrictEqual('/de/test') + }) + + it('URL with LocalizedRoute object with fullPath test and params gets prefixed with /de', () => { + config.storeViews = { + de: { + appendStoreCode: true + } + } + + const LocalizedRoute: LocalizedRoute = { + fullPath: 'test', + params: { + a: 'b', + b: 'a' + } + } + + expect(localizedDispatcherRoute(LocalizedRoute, 'de')).toStrictEqual('/de/test?a=b&b=a') + }) + }) + + describe('setupMultistoreRoutes', () => { + it('Add new routes for each store in mapStoreUrlsFor', () => { + config.storeViews = { + 'de': { + appendStoreCode: true + }, + mapStoreUrlsFor: [ + 'de' + ], + multistore: true + } + config.seo = { + useUrlDispatcher: true + } + + const routeConfig: RouteConfig[] = [ + { + path: 'test' + }, + { + path: 'test2' + } + ] + + setupMultistoreRoutes(config, router, routeConfig) + + expect(router.addRoutes).toBeCalledTimes(1) + }) + + it('Do nothing as mapStoreUrlsFor is empty', () => { + config.storeViews = { + 'de': { + }, + mapStoreUrlsFor: [] + } + + const routeConfig: RouteConfig[] = [ + { + path: 'test' + }, + { + path: 'test2' + } + ] + + setupMultistoreRoutes(config, router, routeConfig) + + expect(router.addRoutes).toBeCalledTimes(1) + }) + }) + + describe('localizedRoutePath', () => { + it('add storeCode to route path with slash', () => { + const storeCode = 'de' + const path = '/test' + + expect(localizedRoutePath(path, storeCode)).toBe('/de/test') + }) + + it('add storeCode to route path without slash', () => { + const storeCode = 'de' + const path = 'test' + + expect(localizedRoutePath(path, storeCode)).toBe('/de/test') + }) + + it('add storeCode to route path with hash', () => { + const storeCode = 'de' + const path = '/test#test' + + expect(localizedRoutePath(path, storeCode)).toBe('/de/test#test') + }) + }) + + describe('localizedRouteConfig', () => { + it('create new route object with storeCode', () => { + const storeCode = 'de' + const route = { + path: '/test', + name: 'test' + } + const expectedRoute = { + path: '/de/test', + name: 'de-test' + } + + expect(localizedRouteConfig(route, storeCode)).toEqual(expectedRoute) + }) + + it('change only route name for child route', () => { + const storeCode = 'de' + const childRoute = { + path: '/test2', + name: 'test2' + } + const expectedRoute = { + path: '/test2', + name: 'de-test2' + } + + expect(localizedRouteConfig(childRoute, storeCode, true)).toEqual(expectedRoute) + }) + + it('add localization for nested routes', () => { + const storeCode = 'de' + const route = { + path: '/test', + name: 'test', + children: [ + { + path: 'test2', + name: 'test2', + children: [ + { + path: '/test3', + name: 'test3' + } + ] + } + ] + } + const expectedRoute = { + path: '/de/test', + name: 'de-test', + children: [ + { + path: 'test2', + name: 'de-test2', + children: [ + { + path: '/test3', + name: 'de-test3' + } + ] + } + ] + } + + expect(localizedRouteConfig(route, storeCode)).toEqual(expectedRoute) + }) + }) }) diff --git a/core/lib/types.ts b/core/lib/types.ts index 0f76fadfe..507806fab 100644 --- a/core/lib/types.ts +++ b/core/lib/types.ts @@ -2,13 +2,14 @@ export interface LocalizedRoute { path?: string, name?: string, hash?: string, - params?: object, + params?: { [key: string]: unknown }, fullPath?: string, host?: string } export interface StoreView { storeCode: string, + extend?: string, disabled?: boolean, storeId: any, name?: string, @@ -22,7 +23,9 @@ export interface StoreView { sourcePriceIncludesTax: boolean, defaultCountry: string, defaultRegion: null | string, - calculateServerSide: boolean + calculateServerSide: boolean, + userGroupId?: number, + useOnlyDefaultUserGroupId: boolean }, i18n: { fullCountryName: string, @@ -33,5 +36,8 @@ export interface StoreView { currencyCode: string, currencySign: string, dateFormat: string + }, + seo: { + defaultTitle: string } } diff --git a/core/mixins/composite.js b/core/mixins/composite.js index fe080fbf3..79345e6eb 100644 --- a/core/mixins/composite.js +++ b/core/mixins/composite.js @@ -1,7 +1,7 @@ import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' import { Logger } from '@vue-storefront/core/lib/logger' -// to be deprecated +// @deprecated from 2.0 export default { beforeCreated () { const eventName = this.$options.name.toLowerCase() + '-before-created' diff --git a/core/mixins/onBottomScroll.js b/core/mixins/onBottomScroll.js index f0fc26810..b29d8fc18 100644 --- a/core/mixins/onBottomScroll.js +++ b/core/mixins/onBottomScroll.js @@ -1,4 +1,17 @@ -import { isBottomVisible } from '@vue-storefront/core/helpers' +import { isServer } from '@vue-storefront/core/helpers' + +const isBottomVisible = () => { + if (isServer) { + return false + } + const SAFETY_MARGIN = 20 + const scrollY = window.scrollY + const visible = window.innerHeight + const pageHeight = document.documentElement.scrollHeight + const bottomOfPage = scrollY + SAFETY_MARGIN >= pageHeight - visible + + return bottomOfPage || pageHeight < visible +} /** * By implementing this mixin add "onBottomScroll" mthod in component. diff --git a/core/mixins/thumbnail.js b/core/mixins/thumbnail.js index 0dafcc09e..0ffff030f 100644 --- a/core/mixins/thumbnail.js +++ b/core/mixins/thumbnail.js @@ -1,15 +1,24 @@ -import { getThumbnailPath as _thumbnailHelper } from '@vue-storefront/core/helpers' +import { getThumbnailPath } from '@vue-storefront/core/helpers' export const thumbnail = { methods: { /** - * Return thumbnail URL for specific base url - * @param {String} relativeUrl - * @param {Int} width - * @param {Int} height + * Return thumbnail URL for specific base url and path + * @param {string} relativeUrl + * @param {number} width + * @param {number} height + * @param {string} pathType + * @returns {string} */ - getThumbnail (relativeUrl, width, height) { - return _thumbnailHelper(relativeUrl, width, height) - } + getThumbnail: (relativeUrl, width, height, pathType) => getThumbnailPath(relativeUrl, width, height, pathType), + + /** + * Return thumbnail URL for specific base url using media path + * @param {string} relativeUrl + * @param {number} width + * @param {number} height + * @returns {string} + */ + getMediaThumbnail: (relativeUrl, width, height) => getThumbnailPath(relativeUrl, width, height, 'media') } } diff --git a/core/modules-entry.ts b/core/modules-entry.ts index 2d2671b58..fd8f38bf6 100644 --- a/core/modules-entry.ts +++ b/core/modules-entry.ts @@ -1,14 +1,8 @@ import { VueStorefrontModule } from '@vue-storefront/core/lib/module' -import { Cms } from './modules/cms' -import { Order } from './modules/order' -import { User } from './modules/user' -import { registerModules } from 'src/modules' -import { Breadcrumbs } from './modules/breadcrumbs' +import { registerModules } from 'src/modules/client' + +// @deprecated from 2.0, use registerModule instead export const enabledModules: VueStorefrontModule[] = [ - Breadcrumbs, - Cms, - Order, - User, ...registerModules ] diff --git a/core/modules/breadcrumbs/components/Breadcrumbs.ts b/core/modules/breadcrumbs/components/Breadcrumbs.ts index f1e96e11c..b0c097acb 100644 --- a/core/modules/breadcrumbs/components/Breadcrumbs.ts +++ b/core/modules/breadcrumbs/components/Breadcrumbs.ts @@ -1,10 +1,42 @@ +import { localizedRoute, currentStoreView } from '@vue-storefront/core/lib/multistore' +import i18n from '@vue-storefront/i18n' +import { mapGetters } from 'vuex' + export const Breadcrumbs = { computed: { - routes () { - return this.$store.state.breadcrumbs.routes + ...mapGetters({ + getBreadcrumbsRoutes: 'breadcrumbs/getBreadcrumbsRoutes', + getBreadcrumbsCurrent: 'breadcrumbs/getBreadcrumbsCurrent' + }), + paths () { + const routes = this.routes ? this.routes : this.getBreadcrumbsRoutes + + if (this.withHomepage) { + return [ + { name: i18n.t('Homepage'), route_link: localizedRoute('/', currentStoreView().storeCode) }, + ...routes + ] + } + + return routes }, current () { - return this.$store.state.breadcrumbs.current + return this.activeRoute || this.getBreadcrumbsCurrent + } + }, + props: { + routes: { + type: Array, + required: false, + default: null + }, + withHomepage: { + type: Boolean, + default: false + }, + activeRoute: { + type: String, + default: '' } } } diff --git a/core/modules/breadcrumbs/index.ts b/core/modules/breadcrumbs/index.ts index 6530e8cad..5243571e5 100644 --- a/core/modules/breadcrumbs/index.ts +++ b/core/modules/breadcrumbs/index.ts @@ -1,10 +1,6 @@ -import { module } from './store' -import { createModule } from '@vue-storefront/core/lib/module' +import { breadcrumbsStore } from './store' +import { StorefrontModule } from '@vue-storefront/core/lib/modules' -export const KEY = 'breadcrumbs' -export const Breadcrumbs = createModule({ - key: KEY, - store: { modules: [ - { key: KEY, module: module } - ] } -}) +export const BreadcrumbsModule: StorefrontModule = function ({store}) { + store.registerModule('breadcrumbs', breadcrumbsStore) +} diff --git a/core/modules/breadcrumbs/store/index.ts b/core/modules/breadcrumbs/store/index.ts index c29ec4e40..02f195275 100644 --- a/core/modules/breadcrumbs/store/index.ts +++ b/core/modules/breadcrumbs/store/index.ts @@ -1,5 +1,5 @@ -export const module = { +export const breadcrumbsStore = { namespaced: true, state: { routes: [], @@ -15,5 +15,9 @@ export const module = { set ({ commit }, payload) { commit('set', payload) } + }, + getters: { + getBreadcrumbsRoutes: (state) => state.routes, + getBreadcrumbsCurrent: (state) => state.current } } diff --git a/core/modules/breadcrumbs/test/unit/parseCategoryPath.spec.ts b/core/modules/breadcrumbs/test/unit/parseCategoryPath.spec.ts new file mode 100644 index 000000000..2fb7119ca --- /dev/null +++ b/core/modules/breadcrumbs/test/unit/parseCategoryPath.spec.ts @@ -0,0 +1,175 @@ +import { parseCategoryPath } from '@vue-storefront/core/modules/breadcrumbs/helpers'; +import { Category } from '@vue-storefront/core/modules/catalog-next/types/Category'; +import { currentStoreView } from '@vue-storefront/core/lib/multistore'; + +jest.mock('@vue-storefront/core/app', () => jest.fn()); +jest.mock('@vue-storefront/core/lib/router-manager', () => jest.fn()); +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(), + localizedDispatcherRoute: jest.fn(), + localizedRoute: jest.fn() +})); +jest.mock('@vue-storefront/core/helpers', () => ({ + once: (str) => jest.fn() +})) + +describe('parseCategoryPath method', () => { + describe('on category page', () => { + let categories: Category[]; + + beforeEach(() => { + jest.clearAllMocks(); + (currentStoreView as jest.Mock).mockImplementation(() => ({storeCode: ''})); + categories = [ + { + path: '1/2', + is_active: true, + level: 1, + product_count: 1181, + children_count: '38', + parent_id: 1, + name: 'All', + id: 2, + url_key: 'all-2', + children_data: [], + url_path: 'all-2', + slug: 'all-2' + }, + { path: '1/2/20', + is_active: true, + level: 2, + product_count: 0, + children_count: '8', + parent_id: 2, + name: 'Women', + id: 20, + url_path: 'women/women-20', + url_key: 'women-20', + children_data: [], + slug: 'women-20' + }, + { + path: '1/2/20/21', + is_active: true, + level: 3, + product_count: 0, + children_count: '4', + parent_id: 20, + name: 'Tops', + id: 21, + url_path: 'women/tops-women/tops-21', + url_key: 'tops-21', + children_data: [], + slug: 'tops-21' + } + ]; + }); + + it('should return formatted category path for breadcrumbs', () => { + const result = parseCategoryPath(categories); + const expected = [ + { + name: 'All', + route_link: '/all-2' + }, + { + name: 'Women', + route_link: '/women/women-20' + }, + { + name: 'Tops', + route_link: '/women/tops-women/tops-21' + } + ]; + expect(result).toEqual(expected); + }); + }); + + describe('on product page', () => { + let categories; + + beforeEach(() => { + jest.clearAllMocks(); + (currentStoreView as jest.Mock).mockImplementation(() => ({storeCode: ''})); + categories = [ + { + path: '1/2', + is_active: true, + level: 1, + product_count: 1181, + children_count: '38', + parent_id: 1, + name: 'All', + id: 2, + url_key: 'all-2', + children_data: [], + url_path: 'all-2', + slug: 'all-2' + }, + { path: '1/2/20', + is_active: true, + level: 2, + product_count: 0, + children_count: '8', + parent_id: 2, + name: 'Women', + id: 20, + url_path: 'women/women-20', + url_key: 'women-20', + children_data: [], + slug: 'women-20' + }, + { + path: '1/2/20/21', + is_active: true, + level: 3, + product_count: 0, + children_count: '4', + parent_id: 20, + name: 'Tops', + id: 21, + url_path: 'women/tops-women/tops-21', + url_key: 'tops-21', + children_data: [], + slug: 'tops-21' + }, + { + path: '1/2/20/21/23', + is_active: true, + level: 4, + product_count: 186, + children_count: '0', + parent_id: 21, + name: 'Jackets', + id: 23, + url_key: 'jackets-23', + url_path: 'women/tops-women/jackets-women/jackets-23', + slug: 'jackets-23' + } + ]; + }); + + it('should return formatted category path for breadcrumbs', () => { + const result = parseCategoryPath(categories); + const expected = [ + { + name: 'All', + route_link: '/all-2' + }, + { + name: 'Women', + route_link: '/women/women-20' + }, + { + name: 'Tops', + route_link: '/women/tops-women/tops-21' + }, + { + name: 'Jackets', + route_link: '/women/tops-women/jackets-women/jackets-23' + } + ]; + expect(result).toEqual(expected); + }); + }); +}); diff --git a/core/modules/cart/components/AddToCart.ts b/core/modules/cart/components/AddToCart.ts index e7ba46d47..73f447159 100644 --- a/core/modules/cart/components/AddToCart.ts +++ b/core/modules/cart/components/AddToCart.ts @@ -1,5 +1,7 @@ import Product from '@vue-storefront/core/modules/catalog/types/Product' +import { Logger } from '@vue-storefront/core/lib/logger'; +// @deprecated moved to store export const AddToCart = { name: 'AddToCart', data () { @@ -22,6 +24,7 @@ export const AddToCart = { this.isAddingToCart = true try { const diffLog = await this.$store.dispatch('cart/addItem', { productToAdd: product }) + if (diffLog) { if (diffLog.clientNotifications && diffLog.clientNotifications.length > 0) { diffLog.clientNotifications.forEach(notificationData => { diff --git a/core/modules/cart/components/Microcart.ts b/core/modules/cart/components/Microcart.ts index e61efa08e..667c2c1aa 100644 --- a/core/modules/cart/components/Microcart.ts +++ b/core/modules/cart/components/Microcart.ts @@ -2,6 +2,7 @@ import AppliedCoupon from '../types/AppliedCoupon' import Product from '@vue-storefront/core/modules/catalog/types/Product' import CartTotalSegments from '../types/CartTotalSegments' +// @deprecated moved to store export const Microcart = { name: 'Microcart', computed: { diff --git a/core/modules/cart/components/MicrocartButton.ts b/core/modules/cart/components/MicrocartButton.ts index 794e24bb1..0a2b3e9cf 100644 --- a/core/modules/cart/components/MicrocartButton.ts +++ b/core/modules/cart/components/MicrocartButton.ts @@ -1,4 +1,5 @@ +// @deprecated moved to theme export const MicrocartButton = { name: 'MicrocartButton', mounted () { diff --git a/core/modules/cart/components/Product.ts b/core/modules/cart/components/Product.ts index 0c07dc86d..bd30e7617 100644 --- a/core/modules/cart/components/Product.ts +++ b/core/modules/cart/components/Product.ts @@ -1,6 +1,6 @@ -import { productThumbnailPath } from '@vue-storefront/core/helpers' -import config from 'config' +import { getThumbnailForProduct, getProductConfiguration } from '@vue-storefront/core/modules/cart/helpers' +// @deprecated moved to theme export const MicrocartProduct = { name: 'MicrocartProduct', props: { @@ -11,10 +11,10 @@ export const MicrocartProduct = { }, computed: { thumbnail () { - const thumbnail = productThumbnailPath(this.product) - if (typeof navigator !== 'undefined' && !navigator.onLine) { - return this.getThumbnail(thumbnail, config.products.thumbnails.width, config.products.thumbnails.height) // for offline support we do need to have ProductTile version - } else return this.getThumbnail(thumbnail, config.cart.thumbnails.width, config.cart.thumbnails.height) + return getThumbnailForProduct(this.product) + }, + configuration () { + return getProductConfiguration(this.product) } }, methods: { diff --git a/core/modules/cart/helpers/calculateTotals.ts b/core/modules/cart/helpers/calculateTotals.ts new file mode 100644 index 000000000..b7c54b474 --- /dev/null +++ b/core/modules/cart/helpers/calculateTotals.ts @@ -0,0 +1,41 @@ +import i18n from '@vue-storefront/i18n' +import sumBy from 'lodash-es/sumBy' +import ShippingMethod from '@vue-storefront/core/modules/cart/types/ShippingMethod' +import PaymentMethod from '@vue-storefront/core/modules/cart/types/PaymentMethod' +import CartItem from '@vue-storefront/core/modules/cart/types/CartItem' + +const calculateTotals = (shippingMethod: ShippingMethod, paymentMethod: PaymentMethod, cartItems: CartItem[]) => { + const shippingTax = shippingMethod ? shippingMethod.price_incl_tax : 0 + + const totalsArray = [ + { + code: 'subtotal_incl_tax', + title: i18n.t('Subtotal incl. tax'), + value: sumBy(cartItems, (p) => p.qty * p.price_incl_tax) + }, + { + code: 'grand_total', + title: i18n.t('Grand total'), + value: sumBy(cartItems, (p) => p.qty * p.price_incl_tax + shippingTax) + } + ] + + if (paymentMethod) { + totalsArray.push({ + code: 'payment', + title: i18n.t(paymentMethod.title), + value: paymentMethod.cost_incl_tax + }) + } + if (shippingMethod) { + totalsArray.push({ + code: 'shipping', + title: i18n.t(shippingMethod.method_title), + value: shippingMethod.price_incl_tax + }) + } + + return totalsArray +} + +export default calculateTotals diff --git a/core/modules/cart/helpers/cartCacheHandler.ts b/core/modules/cart/helpers/cartCacheHandler.ts index 793d85308..7041e16fb 100644 --- a/core/modules/cart/helpers/cartCacheHandler.ts +++ b/core/modules/cart/helpers/cartCacheHandler.ts @@ -1,5 +1,7 @@ import * as types from '../store/mutation-types'; +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' + export function cartCacheHandlerFactory (Vue) { return (mutation, state) => { const type = mutation.type; @@ -12,19 +14,19 @@ export function cartCacheHandlerFactory (Vue) { type.endsWith(types.CART_DEL_NON_CONFIRMED_ITEM) || type.endsWith(types.CART_UPD_ITEM_PROPS) ) { - return Vue.prototype.$db.cartsCollection.setItem('current-cart', state.cart.cartItems).catch((reason) => { + return StorageManager.get('cart').setItem('current-cart', state.cart.cartItems).catch((reason) => { console.error(reason) // it doesn't work on SSR }) // populate cache } else if ( type.endsWith(types.CART_LOAD_CART_SERVER_TOKEN) ) { - return Vue.prototype.$db.cartsCollection.setItem('current-cart-token', state.cart.cartServerToken).catch((reason) => { + return StorageManager.get('cart').setItem('current-cart-token', state.cart.cartServerToken).catch((reason) => { console.error(reason) }) } else if ( type.endsWith(types.CART_SET_ITEMS_HASH) ) { - return Vue.prototype.$db.cartsCollection.setItem('current-cart-hash', state.cart.cartItemsHash).catch((reason) => { + return StorageManager.get('cart').setItem('current-cart-hash', state.cart.cartItemsHash).catch((reason) => { console.error(reason) }) } diff --git a/core/modules/cart/helpers/createCartItemForUpdate.ts b/core/modules/cart/helpers/createCartItemForUpdate.ts new file mode 100644 index 000000000..8380fa896 --- /dev/null +++ b/core/modules/cart/helpers/createCartItemForUpdate.ts @@ -0,0 +1,23 @@ +import config from 'config' +import CartItem from '@vue-storefront/core/modules/cart/types/CartItem'; + +const createCartItemForUpdate = (clientItem: CartItem, serverItem: any, updateIds: boolean = false): CartItem => { + const sku = clientItem.parentSku && config.cart.setConfigurableProductOptions ? clientItem.parentSku : clientItem.sku + const cartItem = { + sku, + qty: clientItem.qty, + product_option: clientItem.product_option + } as any as CartItem + + if (updateIds && serverItem.quote_id && serverItem.item_id) { + return { + ...cartItem, + quoteId: serverItem.quote_id, + item_id: serverItem.item_id + } + } + + return cartItem +} + +export default createCartItemForUpdate diff --git a/core/modules/cart/helpers/createDiffLog.ts b/core/modules/cart/helpers/createDiffLog.ts new file mode 100644 index 000000000..597aa159e --- /dev/null +++ b/core/modules/cart/helpers/createDiffLog.ts @@ -0,0 +1,72 @@ +import { Notification, ServerResponse, Party } from '@vue-storefront/core/modules/cart/types/DiffLog' + +class DiffLog { + public items: Party[] + public serverResponses: ServerResponse[] + public clientNotifications: Notification[] + + public constructor () { + this.items = [] + this.serverResponses = [] + this.clientNotifications = [] + } + + public pushParty (party: Party): DiffLog { + this.items.push(party) + return this + } + + public pushClientParty (party: any): DiffLog { + this.pushParty({ party: 'client', ...party }) + return this + } + + public pushServerParty (party: any): DiffLog { + this.pushParty({ party: 'server', ...party }) + return this + } + + public pushServerResponse (response: ServerResponse): DiffLog { + this.serverResponses.push(response) + return this + } + + public pushNotification (notification: Notification): DiffLog { + this.clientNotifications.push(notification) + return this + } + + public pushNotifications (notifications: Notification[]): DiffLog { + this.clientNotifications = this.clientNotifications.concat(notifications) + return this + } + + public merge (diffLog: DiffLog): DiffLog { + this.items = this.items.concat(diffLog.items) + this.serverResponses = this.serverResponses.concat(diffLog.serverResponses) + this.clientNotifications = this.clientNotifications.concat(diffLog.clientNotifications) + return this + } + + public hasClientNotifications () { + return this.clientNotifications.length > 0 + } + + public hasServerResponses () { + return this.serverResponses.length > 0 + } + + public hasParties () { + return this.items.length > 0 + } + + public isEmpty (): boolean { + return !this.hasParties && + !this.hasClientNotifications() && + !this.hasServerResponses() + } +} + +const createDiffLog = (): DiffLog => new DiffLog() + +export default createDiffLog diff --git a/core/modules/cart/helpers/createOrderData.ts b/core/modules/cart/helpers/createOrderData.ts new file mode 100644 index 000000000..37c12e34a --- /dev/null +++ b/core/modules/cart/helpers/createOrderData.ts @@ -0,0 +1,54 @@ +import OrderShippingDetails from '@vue-storefront/core/modules/cart/types/OrderShippingDetails' +import PaymentMethod from '@vue-storefront/core/modules/cart/types/PaymentMethod' +import ShippingMethod from '@vue-storefront/core/modules/cart/types/ShippingMethod' +import CheckoutData from '@vue-storefront/core/modules/cart/types/CheckoutData' +import { currentStoreView } from '@vue-storefront/core/lib/multistore' + +const getDefaultShippingMethod = (shippingMethods: ShippingMethod[] = []): ShippingMethod => { + const onlineShippingMethods = shippingMethods.filter(shippingMethod => !shippingMethod.offline) + if (!onlineShippingMethods.length) return + + return onlineShippingMethods.find(shippingMethod => !!shippingMethod.default) || onlineShippingMethods[0] +} + +const getDefaultPaymentMethod = (paymentMethods: PaymentMethod[] = []): PaymentMethod => { + if (!paymentMethods || !paymentMethods.length) return + + return paymentMethods.find(item => item.default) || paymentMethods[0] +} + +const createOrderData = ({ + shippingDetails, + shippingMethods, + paymentMethods, + paymentDetails, + taxCountry = currentStoreView().tax.defaultCountry +}: CheckoutData): OrderShippingDetails => { + const country = shippingDetails.country ? shippingDetails.country : taxCountry + const shipping = getDefaultShippingMethod(shippingMethods) + const payment = getDefaultPaymentMethod(paymentMethods) + + return { + country, + shippingAddress: { + firstname: shippingDetails.firstName, + lastname: shippingDetails.lastName, + city: shippingDetails.city, + postcode: shippingDetails.zipCode, + street: [shippingDetails.streetAddress] + }, + billingAddress: { + firstname: paymentDetails.firstName, + lastname: paymentDetails.lastName, + city: paymentDetails.city, + postcode: paymentDetails.zipCode, + street: [paymentDetails.streetAddress], + countryId: paymentDetails.country + }, + method_code: shipping && shipping.method_code ? shipping.method_code : null, + carrier_code: shipping && shipping.carrier_code ? shipping.carrier_code : null, + payment_method: payment && payment.code ? payment.code : null + } +} + +export default createOrderData diff --git a/core/modules/cart/helpers/createShippingInfoData.ts b/core/modules/cart/helpers/createShippingInfoData.ts new file mode 100644 index 000000000..e42b0f19f --- /dev/null +++ b/core/modules/cart/helpers/createShippingInfoData.ts @@ -0,0 +1,13 @@ +const createShippingInfoData = (methodsData) => ({ + shippingAddress: { + countryId: methodsData.country, + ...(methodsData.shippingAddress ? methodsData.shippingAddress : {}) + }, + billingAddress: { + ...(methodsData.billingAddress ? methodsData.billingAddress : {}) + }, + shippingCarrierCode: methodsData.carrier_code, + shippingMethodCode: methodsData.method_code +}); + +export default createShippingInfoData diff --git a/core/modules/cart/helpers/getProductConfiguration.ts b/core/modules/cart/helpers/getProductConfiguration.ts new file mode 100644 index 000000000..901a9edd5 --- /dev/null +++ b/core/modules/cart/helpers/getProductConfiguration.ts @@ -0,0 +1,25 @@ +import CartItem from '@vue-storefront/core/modules/cart/types/CartItem' +import { ProductConfiguration } from '@vue-storefront/core/modules/catalog/types/ProductConfiguration' +import getProductOptions from './getProductOptions' + +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])) + + if (!options) { + return null + } + + return ATTRIBUTES.reduce((prev, curr) => ({ + ...prev, + [curr]: { + attribute_code: curr, + ...getAttributesFields(curr) + } + }), {}) as any as ProductConfiguration +} + +export default getProductConfiguration diff --git a/core/modules/cart/helpers/getProductOptions.ts b/core/modules/cart/helpers/getProductOptions.ts new file mode 100644 index 000000000..aa4a5ec36 --- /dev/null +++ b/core/modules/cart/helpers/getProductOptions.ts @@ -0,0 +1,25 @@ +import CartItem from '@vue-storefront/core/modules/cart/types/CartItem' +import { ProductOption } from '@vue-storefront/core/modules/catalog/types/ProductConfiguration' + +const mapValues = (current) => (val) => ({ + id: val.value_index, + label: val.label, + attribute_code: current.attribute_code, + type: current.attribute_code +}) + +const reduceOptions = (prev, curr) => ({ + ...prev, + [curr.attribute_code]: curr.values.map(mapValues(curr)) +}) + +const getProductOptions = (product: CartItem): ProductOption => { + if (!product.configurable_options) { + return null + } + + return product.configurable_options + .reduce(reduceOptions, {}) +} + +export default getProductOptions diff --git a/core/modules/cart/helpers/getThumbnailForProduct.ts b/core/modules/cart/helpers/getThumbnailForProduct.ts new file mode 100644 index 000000000..aa5eafe06 --- /dev/null +++ b/core/modules/cart/helpers/getThumbnailForProduct.ts @@ -0,0 +1,16 @@ +import CartItem from '@vue-storefront/core/modules/cart/types/CartItem' +import config from 'config' +import { getThumbnailPath } from '@vue-storefront/core/helpers' +import { productThumbnailPath } from '@vue-storefront/core/helpers' + +const getThumbnailForProduct = (product: CartItem): string => { + const thumbnail = productThumbnailPath(product) + + if (typeof navigator !== 'undefined' && !navigator.onLine) { + return getThumbnailPath(thumbnail, config.products.thumbnails.width, config.products.thumbnails.height) + } + + return getThumbnailPath(thumbnail, config.cart.thumbnails.width, config.cart.thumbnails.height) +} + +export default getThumbnailForProduct diff --git a/core/modules/cart/helpers/index.ts b/core/modules/cart/helpers/index.ts new file mode 100644 index 000000000..49e97d1bd --- /dev/null +++ b/core/modules/cart/helpers/index.ts @@ -0,0 +1,37 @@ +import { cartCacheHandlerFactory } from './cartCacheHandler' +import optimizeProduct from './optimizeProduct' +import prepareProductsToAdd from './prepareProductsToAdd' +import productChecksum from './productChecksum' +import productsEquals from './productsEquals' +import calculateTotals from './calculateTotals' +import preparePaymentMethodsToSync from './preparePaymentMethodsToSync' +import validateProduct from './validateProduct' +import createDiffLog from './createDiffLog' +import * as notifications from './notifications' +import createCartItemForUpdate from './createCartItemForUpdate' +import prepareShippingInfoForUpdateTotals from './prepareShippingInfoForUpdateTotals' +import getThumbnailForProduct from './getThumbnailForProduct' +import getProductOptions from './getProductOptions' +import getProductConfiguration from './getProductConfiguration' +import createOrderData from './createOrderData' +import createShippingInfoData from './createShippingInfoData' + +export { + cartCacheHandlerFactory, + optimizeProduct, + prepareProductsToAdd, + productChecksum, + productsEquals, + calculateTotals, + preparePaymentMethodsToSync, + validateProduct, + notifications, + createDiffLog, + createCartItemForUpdate, + prepareShippingInfoForUpdateTotals, + getThumbnailForProduct, + getProductOptions, + getProductConfiguration, + createOrderData, + createShippingInfoData +} diff --git a/core/modules/cart/helpers/notifications.ts b/core/modules/cart/helpers/notifications.ts new file mode 100644 index 000000000..06e5872d5 --- /dev/null +++ b/core/modules/cart/helpers/notifications.ts @@ -0,0 +1,51 @@ +import { router } from '@vue-storefront/core/app'; +import { currentStoreView, localizedRoute } from '@vue-storefront/core/lib/multistore'; +import i18n from '@vue-storefront/i18n'; +import config from 'config'; + +const proceedToCheckoutAction = () => ({ + label: i18n.t('Proceed to checkout'), + action: () => router.push(localizedRoute('/checkout', currentStoreView().storeCode)) +}); +const checkoutAction = () => !config.externalCheckout ? proceedToCheckoutAction() : null; + +const productAddedToCart = () => ({ + type: 'success', + message: i18n.t('Product has been added to the cart!'), + action1: { label: i18n.t('OK') }, + action2: checkoutAction() +}) + +const productQuantityUpdated = () => ({ + type: 'success', + message: i18n.t('Product quantity has been updated!'), + action1: { label: i18n.t('OK') }, + action2: checkoutAction() +}) + +const unsafeQuantity = () => ({ + type: 'warning', + message: i18n.t( + 'The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.' + ), + action1: { label: i18n.t('OK') } +}) + +const outOfStock = () => ({ + type: 'error', + message: i18n.t('The product is out of stock and cannot be added to the cart!'), + action1: { label: i18n.t('OK') } +}) + +const createNotification = ({ type, message }) => ({ type, message, action1: { label: i18n.t('OK') } }) +const createNotifications = ({ type, messages }) => + messages.map(message => createNotification({ type, message })); + +export { + createNotification, + createNotifications, + productAddedToCart, + productQuantityUpdated, + unsafeQuantity, + outOfStock +}; diff --git a/core/modules/cart/helpers/optimizeProduct.ts b/core/modules/cart/helpers/optimizeProduct.ts new file mode 100644 index 000000000..20d6326e8 --- /dev/null +++ b/core/modules/cart/helpers/optimizeProduct.ts @@ -0,0 +1,20 @@ +import CartItem from '@vue-storefront/core/modules/cart/types/CartItem' +import config from 'config' +import omit from 'lodash-es/omit' +import pullAll from 'lodash-es/pullAll' + +const optimizeProduct = (product: CartItem): CartItem => { + if (!config.entities.optimize || !config.entities.optimizeShoppingCart) { + return product + } + + let fieldsToOmit = config.entities.optimizeShoppingCartOmitFields + + if (config.cart.productsAreReconfigurable) { + fieldsToOmit = pullAll(fieldsToOmit, ['configurable_children', 'configurable_options']) + } + + return omit(product, fieldsToOmit) +} + +export default optimizeProduct diff --git a/core/modules/cart/helpers/preparePaymentMethodsToSync.ts b/core/modules/cart/helpers/preparePaymentMethodsToSync.ts new file mode 100644 index 000000000..0f4d1d071 --- /dev/null +++ b/core/modules/cart/helpers/preparePaymentMethodsToSync.ts @@ -0,0 +1,28 @@ +import PaymentMethod from '@vue-storefront/core/modules/cart/types/PaymentMethod' + +const isPaymentMethodNotExist = (backendPaymentMethod: PaymentMethod, paymentMethods: PaymentMethod[]) => + typeof backendPaymentMethod === 'object' && !paymentMethods.find(item => item.code === backendPaymentMethod.code) + +const preparePaymentMethodsToSync = ( + backendPaymentMethods: PaymentMethod[], + currentPaymentMethods: PaymentMethod[] +): { uniqueBackendMethods: PaymentMethod[], paymentMethods: PaymentMethod[] } => { + const paymentMethods = [...currentPaymentMethods] + const uniqueBackendMethods = [] + + for (const backendPaymentMethod of backendPaymentMethods) { + if (isPaymentMethodNotExist(backendPaymentMethod, currentPaymentMethods)) { + const backendMethod = { + ...backendPaymentMethod, + is_server_method: true + } + + paymentMethods.push(backendMethod) + uniqueBackendMethods.push(backendMethod) + } + } + + return { uniqueBackendMethods, paymentMethods } +} + +export default preparePaymentMethodsToSync diff --git a/core/modules/cart/helpers/prepareProductsToAdd.ts b/core/modules/cart/helpers/prepareProductsToAdd.ts new file mode 100644 index 000000000..fdae40599 --- /dev/null +++ b/core/modules/cart/helpers/prepareProductsToAdd.ts @@ -0,0 +1,28 @@ +import CartItem from '@vue-storefront/core/modules/cart/types/CartItem' +import productChecksum from './productChecksum' +import optimizeProduct from './optimizeProduct' + +const readAssociated = product => + product.product_links.filter(p => p.link_type === 'associated').map(p => p.product) + +const isDefined = product => typeof product !== 'undefined' || product !== null + +const applyQty = product => ({ + ...product, + qty: product.qty && typeof product.qty !== 'number' ? parseInt(product.qty) : product.qty +}); + +const applyChecksumForBundles = product => + product.type_id === 'bundle' ? { ...product, checksum: productChecksum(product) } : product + +const prepareProductsToAdd = (product: CartItem): CartItem[] => { + const products = product.type_id === 'grouped' ? readAssociated(product) : [product] + + return products + .filter(isDefined) + .map(applyQty) + .map(p => optimizeProduct(p)) + .map(applyChecksumForBundles); +}; + +export default prepareProductsToAdd; diff --git a/core/modules/cart/helpers/prepareShippingInfoForUpdateTotals.ts b/core/modules/cart/helpers/prepareShippingInfoForUpdateTotals.ts new file mode 100644 index 000000000..3004d4035 --- /dev/null +++ b/core/modules/cart/helpers/prepareShippingInfoForUpdateTotals.ts @@ -0,0 +1,22 @@ +import isString from 'lodash-es/isString' +import Totals from '@vue-storefront/core/modules/cart/types/Totals' + +const applyOptions = (item: Totals) => { + if (item.options && isString(item.options)) { + return { ...item, options: JSON.parse(item.options) } + } + + return item +} + +const reduceToObject = (previousValue: any, currentValue: Totals) => ({ + ...previousValue, + [currentValue.item_id]: currentValue +}) + +const prepareShippingInfoForUpdateTotals = (totals: Totals[]) => + totals + .map(applyOptions) + .reduce(reduceToObject, {}) + +export default prepareShippingInfoForUpdateTotals diff --git a/core/modules/cart/helpers/productChecksum.ts b/core/modules/cart/helpers/productChecksum.ts new file mode 100644 index 000000000..ea13e0137 --- /dev/null +++ b/core/modules/cart/helpers/productChecksum.ts @@ -0,0 +1,22 @@ +import CartItem from '@vue-storefront/core/modules/cart/types/CartItem' +import { sha3_224 } from 'js-sha3' + +const getDataToHash = (product: CartItem): any => { + if (!product.product_option) { + return null + } + + const { extension_attributes } = product.product_option + + if (extension_attributes.bundle_options) { + const { bundle_options } = extension_attributes + return Array.isArray(bundle_options) ? bundle_options : Object.values(bundle_options) + } + + return product.product_option +} + +const productChecksum = (product: CartItem): string => + sha3_224(JSON.stringify(getDataToHash(product))) + +export default productChecksum diff --git a/core/modules/cart/helpers/productsEquals.ts b/core/modules/cart/helpers/productsEquals.ts new file mode 100644 index 000000000..92e8870fc --- /dev/null +++ b/core/modules/cart/helpers/productsEquals.ts @@ -0,0 +1,44 @@ +import CartItem from '@vue-storefront/core/modules/cart/types/CartItem' +import productChecksum from './productChecksum'; + +const getChecksum = (product: CartItem) => { + if (product.checksum) { + return product.checksum + } + + return productChecksum(product) +} + +const getProductType = (product: CartItem): string => + product.type_id || product.product_type + +const getServerItemId = (product: CartItem): string | number => + product.server_item_id || product.item_id + +const isServerIdsEquals = (product1: CartItem, product2: CartItem): boolean => { + const product1ItemId = getServerItemId(product1) + const product2ItemId = getServerItemId(product2) + const areItemIdsDefined = product1ItemId !== undefined && product2ItemId !== undefined + + return areItemIdsDefined && product1ItemId === product2ItemId +} + +const isChecksumEquals = (product1: CartItem, product2: CartItem): boolean => + getChecksum(product1) === getChecksum(product2) + +const productsEquals = (product1: CartItem, product2: CartItem): boolean => { + if (!product1 || !product2) { + return false + } + + const typeProduct1 = getProductType(product1) + const typeProduct2 = getProductType(product2) + + if (typeProduct1 === 'bundle' || typeProduct2 === 'bundle') { + return isServerIdsEquals(product1, product2) || isChecksumEquals(product1, product2) + } + + return String(product1.sku) === String(product2.sku) +} + +export default productsEquals diff --git a/core/modules/cart/helpers/validateProduct.ts b/core/modules/cart/helpers/validateProduct.ts new file mode 100644 index 000000000..c348d86b7 --- /dev/null +++ b/core/modules/cart/helpers/validateProduct.ts @@ -0,0 +1,23 @@ +import config from 'config' +import i18n from '@vue-storefront/i18n' +import CartItem from '@vue-storefront/core/modules/cart/types/CartItem'; + +const validateProduct = (product: CartItem): string[] => { + const errors = [] + + if (config.useZeroPriceProduct ? product.price_incl_tax < 0 : product.price_incl_tax <= 0) { + errors.push(i18n.t('Product price is unknown, product cannot be added to the cart!')) + } + + if (product.errors !== null && typeof product.errors !== 'undefined') { + for (const errKey in product.errors) { + if (product.errors[errKey]) { + errors.push(product.errors[errKey]) + } + } + } + + return errors +}; + +export default validateProduct; diff --git a/core/modules/cart/hooks/afterRegistration.ts b/core/modules/cart/hooks/afterRegistration.ts deleted file mode 100644 index 246c78852..000000000 --- a/core/modules/cart/hooks/afterRegistration.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { cartCacheHandlerFactory } from '../helpers/cartCacheHandler'; - -export function afterRegistration ({ Vue, config, store, isServer }) { - if (!isServer) store.dispatch('cart/load') - - store.subscribe(cartCacheHandlerFactory(Vue)) -} diff --git a/core/modules/cart/hooks/beforeRegistration.ts b/core/modules/cart/hooks/beforeRegistration.ts deleted file mode 100644 index 7aeda78bc..000000000 --- a/core/modules/cart/hooks/beforeRegistration.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as localForage from 'localforage' -import UniversalStorage from '@vue-storefront/core/store/lib/storage' -import { currentStoreView } from '@vue-storefront/core/lib/multistore' - -export function beforeRegistration ({ Vue, config, store, isServer }) { - const storeView = currentStoreView() - const dbNamePrefix = storeView.storeCode ? storeView.storeCode + '-' : '' - - Vue.prototype.$db.cartsCollection = new UniversalStorage(localForage.createInstance({ - name: (config.storeViews.commonCache ? '' : dbNamePrefix) + 'shop', - storeName: 'carts', - driver: localForage[config.localForage.defaultDrivers['carts']] - })) -} diff --git a/core/modules/cart/hooks/index.ts b/core/modules/cart/hooks/index.ts new file mode 100644 index 000000000..6d08ad598 --- /dev/null +++ b/core/modules/cart/hooks/index.ts @@ -0,0 +1,69 @@ +import { createListenerHook, createMutatorHook } from '@vue-storefront/core/lib/hooks' +import CartItem from '../types/CartItem'; + +const { + hook: beforeSyncHook, + executor: beforeSyncExecutor +} = createMutatorHook<{ clientItems: CartItem[], serverItems: CartItem[] }, any>() + +const { + hook: afterSyncHook, + executor: afterSyncExecutor +} = createListenerHook() + +const { + hook: beforeAddToCartHook, + executor: beforeAddToCartExecutor +} = createMutatorHook<{ cartItem: CartItem }, any>() + +const { + hook: afterAddToCartHook, + executor: afterAddToCartExecutor +} = createListenerHook() + +const { + hook: beforeRemoveFromCartHook, + executor: beforeRemoveFromCartExecutor +} = createMutatorHook<{ cartItem: CartItem }, any>() + +const { + hook: afterRemoveFromCartHook, + executor: afterRemoveFromCartExecutor +} = createListenerHook() + +const { + hook: beforeMergeHook, + executor: beforeMergeExecutor +} = createMutatorHook<{ clientItems: CartItem[], serverItems: CartItem[] }, any>() + +const { + hook: afterLoadHook, + executor: afterLoadExecutor +} = createListenerHook() + +const cartHooksExecutors = { + beforeSync: beforeSyncExecutor, + afterSync: afterSyncExecutor, + beforeAddToCart: beforeAddToCartExecutor, + afterAddToCart: afterAddToCartExecutor, + beforeRemoveFromCart: beforeRemoveFromCartExecutor, + afterRemoveFromCart: afterRemoveFromCartExecutor, + beforeMerge: beforeMergeExecutor, + afterLoad: afterLoadExecutor +} + +const cartHooks = { + beforeSync: beforeSyncHook, + afterSync: afterSyncHook, + beforeAddToCart: beforeAddToCartHook, + afterAddToCart: afterAddToCartHook, + beforeRemoveFromCart: beforeRemoveFromCartHook, + afterRemoveFromCart: afterRemoveFromCartHook, + beforeMerge: beforeMergeHook, + afterLoad: afterLoadHook +} + +export { + cartHooks, + cartHooksExecutors +} diff --git a/core/modules/cart/index.ts b/core/modules/cart/index.ts index 051741d91..788cd316b 100644 --- a/core/modules/cart/index.ts +++ b/core/modules/cart/index.ts @@ -1,12 +1,15 @@ -import { module } from './store' -import { createModule } from '@vue-storefront/core/lib/module' -import { beforeRegistration } from './hooks/beforeRegistration' -import { afterRegistration } from './hooks/afterRegistration' +import { StorefrontModule } from '@vue-storefront/core/lib/modules' +import { cartStore } from './store' +import { cartCacheHandlerFactory } from './helpers'; +import { isServer } from '@vue-storefront/core/helpers' +import Vue from 'vue' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' -export const KEY = 'cart' -export const Cart = createModule({ - key: KEY, - store: { modules: [{ key: KEY, module }] }, - beforeRegistration, - afterRegistration -}) +export const CartModule: StorefrontModule = function ({store}) { + StorageManager.init('cart') + + store.registerModule('cart', cartStore) + + if (!isServer) store.dispatch('cart/load') + store.subscribe(cartCacheHandlerFactory(Vue)) +} diff --git a/core/modules/cart/store/actions.ts b/core/modules/cart/store/actions.ts deleted file mode 100644 index b0b63c15c..000000000 --- a/core/modules/cart/store/actions.ts +++ /dev/null @@ -1,764 +0,0 @@ -import Vue from 'vue' -import { ActionTree } from 'vuex' -import * as types from './mutation-types' -import i18n from '@vue-storefront/i18n' -import { currentStoreView, localizedRoute } from '@vue-storefront/core/lib/multistore' -import omit from 'lodash-es/omit' -import RootState from '@vue-storefront/core/types/RootState' -import CartState from '../types/CartState' -import isString from 'lodash-es/isString' -import toString from 'lodash-es/toString' -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' -import config from 'config' -import Task from '@vue-storefront/core/lib/sync/types/Task' - -const MAX_BYPASS_COUNT = 10 -let _connectBypassCount = 0 - -function _getDifflogPrototype () { - return { items: [], serverResponses: [], clientNotifications: [] } -} - -/** @todo: move this metod to data resolver; shouldn't be a part of public API no more */ -async function _serverShippingInfo ({ methodsData }) { - const task = await TaskQueue.execute({ url: config.cart.shippinginfo_endpoint, - payload: { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - mode: 'cors', - body: JSON.stringify({ - addressInformation: { - shippingAddress: { - countryId: methodsData.country - }, - shippingCarrierCode: methodsData.carrier_code, - shippingMethodCode: methodsData.method_code - } - }) - }, - silent: true - }) - return task -} - -/** @todo: move this metod to data resolver; shouldn't be a part of public API no more */ -async function _serverTotals (): Promise { - return TaskQueue.execute({ url: config.cart.totals_endpoint, // sync the cart - payload: { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - mode: 'cors' - }, - silent: true - }) -} -/** @todo: move this metod to data resolver; shouldn't be a part of public API no more */ -async function _connect ({ guestCart = false, forceClientState = false }): Promise { - const task = { url: guestCart ? config.cart.create_endpoint.replace('{{token}}', '') : config.cart.create_endpoint, // sync the cart - payload: { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - mode: 'cors' - }, - force_client_state: forceClientState, - silent: true - } - return TaskQueue.execute(task) -} -/** @todo: move this metod to data resolver; shouldn't be a part of public API no more */ -function _serverUpdateItem ({ cartServerToken, cartItem }): Promise { - if (!cartItem.quoteId) { - cartItem = Object.assign(cartItem, { quoteId: cartServerToken }) - } - - return TaskQueue.execute({ url: config.cart.updateitem_endpoint, // sync the cart - payload: { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - mode: 'cors', - body: JSON.stringify({ - cartItem: cartItem - }) - } - }) -} - -/** @todo: move this metod to data resolver; shouldn't be a part of public API no more */ -function _serverDeleteItem ({ cartServerToken, cartItem }): Promise { - cartItem = Object.assign(cartItem, { quoteId: cartServerToken }) - return TaskQueue.execute({ url: config.cart.deleteitem_endpoint, // sync the cart - payload: { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - mode: 'cors', - body: JSON.stringify({ - cartItem: cartItem - }) - }, - silent: true - }) -} - -async function _serverGetPaymentMethods (): Promise { - const task = await TaskQueue.execute({ url: config.cart.paymentmethods_endpoint, - payload: { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - mode: 'cors' - }, - silent: true - }) - return task -} - -async function _serverGetShippingMethods (address): Promise { - const task = await TaskQueue.execute({ url: config.cart.shippingmethods_endpoint, - payload: { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - mode: 'cors', - body: JSON.stringify({ - address: address - }) - }, - silent: true - }) - return task -} - -const actions: ActionTree = { - /** Disconnect the shipping cart from sync by clearing out the cart token */ - 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 }) { - await commit(types.CART_LOAD_CART, []) - if (options.recreateAndSyncCart && getters.isCartSyncEnabled) { - await commit(types.CART_LOAD_CART_SERVER_TOKEN, null) - 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 - } - }, - /** Refresh the payment methods with the backend */ - async syncPaymentMethods ({ getters, rootGetters, dispatch }, { forceServerSync = false }) { - if (getters.isCartSyncEnabled && getters.isCartConnected && (getters.isTotalsSyncRequired || forceServerSync)) { - Logger.debug('Refreshing payment methods', 'cart')() - const paymentMethodsTask = await _serverGetPaymentMethods() - let backendMethods = paymentMethodsTask.result - let paymentMethods = rootGetters['payment/paymentMethods'].filter((itm) => { - return (typeof itm !== 'object' || !itm.is_server_method) - }) // copy - let uniqueBackendMethods = [] - for (let i = 0; i < backendMethods.length; i++) { - if (typeof backendMethods[i] === 'object' && !paymentMethods.find(item => item.code === backendMethods[i].code)) { - backendMethods[i].is_server_method = true - paymentMethods.push(backendMethods[i]) - uniqueBackendMethods.push(backendMethods[i]) - } - } - await dispatch('payment/replaceMethods', paymentMethods, { root: true }) - Vue.prototype.$bus.$emit('set-unique-payment-methods', uniqueBackendMethods) - } else { - Logger.debug('Payment methods does not need to be updated', 'cart')() - } - }, - /** Refresh the shipping methods with the backend */ - async syncShippingMethods ({ getters, rootGetters, dispatch }, { forceServerSync = false }) { - if (getters.isCartSyncEnabled && getters.isCartConnected && (getters.isTotalsSyncRequired || forceServerSync)) { - const storeView = currentStoreView() - Logger.debug('Refreshing shipping methods', 'cart')() - let country = rootGetters['checkout/getShippingDetails'].country ? rootGetters['checkout/getShippingDetails'].country : storeView.tax.defaultCountry - const shippingMethodsTask = await _serverGetShippingMethods({ - country_id: country - }) - if (shippingMethodsTask.result.length > 0) { - await dispatch('shipping/replaceMethods', shippingMethodsTask.result.map(method => Object.assign(method, { is_server_method: true })), { root: true }) - } - } else { - Logger.debug('Shipping methods does not need to be updated', 'cart')() - } - }, - /** 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 - const isUserInCheckout = rootGetters['checkout/isUserInCheckout'] - let diffLog = _getDifflogPrototype() - if (isUserInCheckout) forceClientState = true // never surprise the user in checkout - # - if (getters.isCartSyncEnabled && getters.isCartConnected) { - if (getters.isSyncRequired) { // cart hash empty or not changed - /** @todo: move this call to data resolver; shouldn't be a part of public API no more */ - commit(types.CART_SET_SYNC) - const task = await TaskQueue.execute({ url: config.cart.pull_endpoint, // sync the cart - payload: { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - mode: 'cors' - }, - silent: true - }).then(async task => { - if (task.resultCode === 200) { - diffLog = await dispatch('merge', { serverItems: task.result, clientItems: getters.getCartItems, dryRun: dryRun, forceClientState: forceClientState }) - } else { - Logger.error(task.result, 'cart') // override with guest cart() - if (_connectBypassCount < MAX_BYPASS_COUNT) { - Logger.log('Bypassing with guest cart' + _connectBypassCount, 'cart')() - _connectBypassCount = _connectBypassCount + 1 - await dispatch('connect', { guestCart: true }) - Logger.error(task.result, 'cart')() - } - } - }) - return diffLog - } else { - return diffLog - } - } else { - return diffLog - } - }, - /** @deprecated backward compatibility only */ - async serverPull ({ dispatch }, { forceClientState = false, dryRun = false }) { - Logger.warn('The "cart/serverPull" action is deprecated and will not be supported with the Vue Storefront 1.11', 'cart')() - return dispatch('sync', { forceClientState, dryRun }) - }, - /** @description this method is part of "public" cart API */ - async load ({ getters, commit, rootGetters, dispatch }, { forceClientState = false }: {forceClientState?: boolean} = {}) { - if (isServer) return - const cartShippingMethod = getters.getShippingMethod - if ((!cartShippingMethod || !cartShippingMethod.method_code) && (Array.isArray(rootGetters['shipping/shippingMethods']))) { - let shippingMethod = rootGetters['shipping/shippingMethods'].find(item => item.default) - commit(types.CART_UPD_SHIPPING, shippingMethod) - } - const cartPaymentMethpd = getters.getPaymentMethod - if ((!cartPaymentMethpd || !cartPaymentMethpd.code) && Array.isArray(rootGetters['payment/paymentMethods'])) { - let paymentMethod = rootGetters['payment/paymentMethods'].find(item => item.default) - commit(types.CART_UPD_PAYMENT, paymentMethod) - } - const storedItems = await Vue.prototype.$db.cartsCollection.getItem('current-cart') - commit(types.CART_LOAD_CART, storedItems) - if (config.cart.synchronize) { - const token = await Vue.prototype.$db.cartsCollection.getItem('current-cart-token') - const hash = await Vue.prototype.$db.cartsCollection.getItem('current-cart-hash') - if (hash) { - commit(types.CART_SET_ITEMS_HASH, hash) - Logger.info('Cart hash received from cache.', 'cache', hash)() - } - if (token) { // previously set token - commit(types.CART_LOAD_CART_SERVER_TOKEN, token) - 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 }) - } - } - }, - /** Get one single item from the client's cart */ - getItem ({ getters }, sku) { - return getters.getCartItems.find(p => p.sku === sku) - }, - goToCheckout () { - router.push(localizedRoute('/checkout', currentStoreView().storeCode)) - }, - /** add item to the client's cart + sync with server if enabled @description this method is part of "public" cart API */ - async addItem ({ dispatch }, { productToAdd, forceServerSilence = false }) { - let productsToAdd = [] - if (productToAdd.type_id === 'grouped') { // TODO: add bundle support - productsToAdd = productToAdd.product_links.filter((pl) => { return pl.link_type === 'associated' }).map((pl) => { return pl.product }) - } else { - productsToAdd.push(productToAdd) - } - return dispatch('addItems', { productsToAdd: productsToAdd, forceServerSilence }) - }, - /** add multiple items to the client's cart and execute single sync with the server when needed @description this method is part of "public" cart API */ - async addItems ({ commit, dispatch, getters }, { productsToAdd, forceServerSilence = false }) { - let productHasBeenAdded = false - let productIndex = 0 - const diffLog = _getDifflogPrototype() - for (let product of productsToAdd) { - if (typeof product === 'undefined' || product === null) continue - if (product.qty && typeof product.qty !== 'number') product.qty = parseInt(product.qty) - if ((config.useZeroPriceProduct) ? product.priceInclTax < 0 : product.priceInclTax <= 0) { - diffLog.clientNotifications.push({ - type: 'error', - message: i18n.t('Product price is unknown, product cannot be added to the cart!'), - action1: { label: i18n.t('OK') } - }) - continue - } - if (config.entities.optimize && config.entities.optimizeShoppingCart) { - product = omit(product, ['configurable_children', 'configurable_options', 'media_gallery', 'description', 'category', 'category_ids', 'product_links', 'stock', 'description']) - } - if (product.errors !== null && typeof product.errors !== 'undefined') { - let productCanBeAdded = true - for (let errKey in product.errors) { - if (product.errors[errKey]) { - productCanBeAdded = false - diffLog.clientNotifications.push({ - type: 'error', - message: product.errors[errKey], - action1: { label: i18n.t('OK') } - }) - } - } - if (!productCanBeAdded) { - continue - } - } - const record = getters.getCartItems.find(p => p.sku === product.sku) - const result = await dispatch('stock/queueCheck', { product: product, qty: record ? record.qty + 1 : (product.qty ? product.qty : 1) }, {root: true}) // queueCheck returns control immediately and checks in the background; returning just the cached stock data; we're using it because cart/sync checks the stock anyway; but if cart.synchronize is disabeld or offline mode is enabled then this queued check could be usefull there is also `stock/check` actions that returns the exact values - product.onlineStockCheckid = result.onlineCheckTaskId // used to get the online check result - if (result.status === 'volatile') { - diffLog.clientNotifications.push({ - type: 'warning', - message: i18n.t('The system is not sure about the stock quantity (volatile). Product has been added to the cart for pre-reservation.'), - action1: { label: i18n.t('OK') } - }) - } - if (result.status === 'out_of_stock') { - diffLog.clientNotifications.push({ - type: 'error', - message: i18n.t('The product is out of stock and cannot be added to the cart!'), - action1: { label: i18n.t('OK') } - }) - } - if (result.status === 'ok' || result.status === 'volatile') { - commit(types.CART_ADD_ITEM, { product }) - productHasBeenAdded = true - } - if (productIndex === (productsToAdd.length - 1) && productHasBeenAdded) { - let notificationData = { - type: 'success', - message: i18n.t('Product has been added to the cart!'), - action1: { label: i18n.t('OK') }, - action2: null - } - if (!config.externalCheckout) { // if there is externalCheckout enabled we don't offer action to go to checkout as it can generate cart desync - notificationData.action2 = { label: i18n.t('Proceed to checkout'), - action: () => { - dispatch('goToCheckout') - }} - } - if (!getters.isCartSyncEnabled || forceServerSilence) { - diffLog.clientNotifications.push(notificationData) - } - } - productIndex++ - } - if (getters.isCartSyncEnabled && getters.isCartConnected && !forceServerSilence) { - return dispatch('sync', { forceClientState: true }) - } else { - return diffLog - } - }, - /** remove single item from the server cart by payload.sku or by payload.product.sku @description this method is part of "public" cart API */ - async removeItem ({ commit, dispatch, getters }, payload) { - let removeByParentSku = true // backward compatibility call format - let product = payload - if (payload.product) { // new call format since 1.4 - product = payload.product - removeByParentSku = payload.removeByParentSku - } - commit(types.CART_DEL_ITEM, { product, removeByParentSku }) - if (getters.isCartSyncEnabled && product.server_item_id) { - return dispatch('sync', { forceClientState: true }) - } else { - const diffLog = _getDifflogPrototype() - diffLog.items.push({ 'party': 'client', 'status': 'no-item', 'sku': product.sku }) - return diffLog - } - }, - /** this action just updates the product quantity in the cart - by product.sku @description this method is part of "public" cart API */ - async updateQuantity ({ commit, dispatch, getters }, { product, qty, forceServerSilence = false }) { - commit(types.CART_UPD_ITEM, { product, qty }) - if (getters.isCartSyncEnabled && product.server_item_id && !forceServerSilence) { - return dispatch('sync', { forceClientState: true }) - } else { - const diffLog = _getDifflogPrototype() - diffLog.items.push({ 'party': 'client', 'status': 'wrong-qty', 'sku': product.sku, 'client-qty': qty }) - return diffLog - } - }, - /** this action merges in new product properties into existing cart item (by sku) @description this method is part of "public" cart API */ - updateItem ({ commit }, { product }) { - commit(types.CART_UPD_ITEM_PROPS, { product }) - }, - /** refreshes the backend information with the backend @description this method is part of "public" cart API */ - async syncTotals ({ dispatch, commit, getters, rootGetters }, payload: { forceServerSync: boolean, methodsData?: any } = { forceServerSync: false, methodsData: null }) { - let methodsData = payload ? payload.methodsData : null - /** helper method to update the UI */ - const _afterTotals = async (task) => { - if (task.resultCode === 200) { - const totalsObj = task.result.totals ? task.result.totals : task.result - Logger.info('Overriding server totals. ', 'cart', totalsObj)() - const itemsAfterTotal = {} - const platformTotalSegments = totalsObj.total_segments - for (let item of totalsObj.items) { - if (item.options && isString(item.options)) item.options = JSON.parse(item.options) - itemsAfterTotal[item.item_id] = item - await dispatch('updateItem', { product: { server_item_id: item.item_id, totals: item, qty: item.qty } }) // update the server_id reference - } - commit(types.CART_UPD_TOTALS, { itemsAfterTotal: itemsAfterTotal, totals: totalsObj, platformTotalSegments: platformTotalSegments }) - commit(types.CART_SET_TOTALS_SYNC) - } else { - Logger.error(task.result, 'cart')() - } - } - if (getters.isTotalsSyncRequired || payload.forceServerSync) { - await Promise.all([ - dispatch('syncShippingMethods', { forceServerSync: !!payload.forceServerSync }), // pull the shipping and payment methods available for the current cart content - dispatch('syncPaymentMethods', { forceServerSync: !!payload.forceServerSync }) // pull the shipping and payment methods available for the current cart content - ]) - } else { - Logger.debug('Skipping payment & shipping methods update as cart has not been changed', 'cart')() - } - const storeView = currentStoreView() - let hasShippingInformation = !!(methodsData && methodsData.method_code) - if (getters.isTotalsSyncEnabled && getters.isCartConnected && (getters.isTotalsSyncRequired || payload.forceServerSync)) { - if (!methodsData) { - const country = rootGetters['checkout/getShippingDetails'].country ? rootGetters['checkout/getShippingDetails'].country : storeView.tax.defaultCountry - const shippingMethods = rootGetters['shipping/shippingMethods'] - const paymentMethods = rootGetters['payment/paymentMethods'] - let shipping = shippingMethods && Array.isArray(shippingMethods) ? shippingMethods.find(item => item.default && !item.offline /* don't sync offline only shipping methods with the serrver */) : null - let payment = paymentMethods && Array.isArray(paymentMethods) ? paymentMethods.find(item => item.default) : null - if (!shipping && shippingMethods && shippingMethods.length > 0) { - shipping = shippingMethods.find(item => !item.offline) - } - if (!payment && paymentMethods && paymentMethods.length > 0) { - payment = paymentMethods[0] - } - methodsData = { - country: country - } - if (shipping) { - if (shipping.method_code) { - hasShippingInformation = true // there are some edge cases when the backend returns no shipping info - methodsData['method_code'] = shipping.method_code - } - if (shipping.carrier_code) { - hasShippingInformation = true - methodsData['carrier_code'] = shipping.carrier_code - } - } - if (payment && payment.code) methodsData['payment_method'] = payment.code - } - if (methodsData.country && getters.isCartConnected) { - if (hasShippingInformation) { - return _serverShippingInfo({ methodsData }).then(_afterTotals) - } else { - return _serverTotals().then(_afterTotals) - } - } else { - Logger.error('Please do set the tax.defaultCountry in order to calculate totals', 'cart')() - } - } - }, - async refreshTotals ({ dispatch }, payload) { - Logger.warn('The "cart/refreshTotals" action is deprecated and will not be supported with the Vue Storefront 1.11', 'cart')() - return dispatch('syncTotals', payload) - }, - /** remove discount code from the cart + sync totals @description this method is part of "public" cart API */ - async removeCoupon ({ getters, dispatch }) { - if (getters.isTotalsSyncEnabled && getters.isCartConnected) { - const task = await TaskQueue.execute({ url: config.cart.deletecoupon_endpoint, - payload: { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - mode: 'cors' - }, - silent: false - }) - if (task.result) { - dispatch('syncTotals', { forceServerSync: true }) - return task.result - } - } - return null - }, - /** add discount code to the cart + refresh totals @description this method is part of "public" cart API */ - async applyCoupon ({ getters, dispatch }, couponCode) { - if (getters.isTotalsSyncEnabled && getters.isCartConnected) { - const task = await TaskQueue.execute({ url: config.cart.applycoupon_endpoint.replace('{{coupon}}', couponCode), - payload: { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - mode: 'cors' - }, - silent: true - }) - if (task.result === true) { - dispatch('syncTotals', { forceServerSync: true }) - return true - } - } - return 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 }) - } - }) - }, - /** connect cart to the server and set the cart token */ - async connect ({ getters, dispatch, commit }, { guestCart = false, forceClientState = false }) { - if (getters.isCartSyncEnabled) { - return _connect({ guestCart, forceClientState }).then(task => { - const cartToken = task.result - 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 }) - } 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 - if (_connectBypassCount < MAX_BYPASS_COUNT) { - Logger.log('Bypassing with guest cart' + _connectBypassCount, 'cart')() - _connectBypassCount = _connectBypassCount + 1 - Logger.error(task.result, 'cart')() - return dispatch('connect', { guestCart: true }) - } - } - } - }) - } else { - Logger.warn('Cart sync is disabled by the config', 'cart')() - return _getDifflogPrototype() - } - }, - /** 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 }) { - const diffLog = _getDifflogPrototype() - let totalsShouldBeRefreshed = getters.isTotalsSyncRequired // when empty it means no sync has yet been executed - let serverCartUpdateRequired = false - let clientCartUpdateRequired = false - let cartHasItems = false - const clientCartAddItems = [] - - /** helper to find the item to be added to the cart by sku */ - const productActionOptions = async (serverItem) => { - if (serverItem.product_type === 'configurable') { - 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 }) - - return items.length >= 1 ? { sku: items[0].sku, childSku: serverItem.sku } : null - } - return { sku: serverItem.sku } - } - /** helper - sub method to update the item in the cart */ - const _updateClientItem = async function ({ dispatch }, event, clientItem) { - if (typeof event.result.item_id !== 'undefined') { - await dispatch('updateItem', { product: { server_item_id: event.result.item_id, sku: clientItem.sku, server_cart_id: event.result.quote_id, prev_qty: clientItem.qty } }) // update the server_id reference - Vue.prototype.$bus.$emit('cart-after-itemchanged', { item: clientItem }) - } - } - - /** helper - sub method to react for the server response after the sync */ - const _afterServerItemUpdated = async function ({ dispatch, commit }, event, clientItem = null) { - Logger.debug('Cart item server sync' + event, 'cart')() - diffLog.serverResponses.push({ 'status': event.resultCode, 'sku': clientItem.sku, 'result': event }) - if (event.resultCode !== 200) { - // TODO: add the strategy to configure behaviour if the product is (confirmed) out of the stock - if (clientItem.server_item_id) { - dispatch('getItem', clientItem.sku).then((cartItem) => { - if (cartItem) { - Logger.log('Restoring qty after error' + clientItem.sku + cartItem.prev_qty, 'cart')() - if (cartItem.prev_qty > 0) { - dispatch('updateItem', { product: { qty: cartItem.prev_qty } }) // update the server_id reference - Vue.prototype.$bus.$emit('cart-after-itemchanged', { item: cartItem }) - } else { - dispatch('removeItem', { product: cartItem, removeByParentSku: false }) // update the server_id reference - } - } - }) - } else { - Logger.warn('Removing product from cart', 'cart', clientItem)() - commit(types.CART_DEL_NON_CONFIRMED_ITEM, { product: clientItem }) - } - } else { - const isUserInCheckout = rootGetters['checkout/isUserInCheckout'] - if (!isUserInCheckout) { // if user is in the checkout - this callback is just a result of server sync - const isThisNewItemAddedToTheCart = (!clientItem || !clientItem.server_item_id) - const notificationData = { - type: 'success', - message: isThisNewItemAddedToTheCart ? i18n.t('Product has been added to the cart!') : i18n.t('Product quantity has been updated!'), - action1: { label: i18n.t('OK') }, - action2: null - } - if (!config.externalCheckout) { // if there is externalCheckout enabled we don't offer action to go to checkout as it can generate cart desync - notificationData.action2 = { label: i18n.t('Proceed to checkout'), - action: () => { - dispatch('goToCheckout') - }} - } - diffLog.clientNotifications.push(notificationData) // display the notification only for newly added products - } - } - if (clientItem === null) { - const cartItem = await dispatch('getItem', event.result.sku) - if (cartItem) { - await _updateClientItem({ dispatch }, event, cartItem) - } - } else { - await _updateClientItem({ dispatch }, event, clientItem) - } - } - for (const clientItem of clientItems) { - cartHasItems = true - const serverItem = serverItems.find((itm) => { - return String(itm.sku) === String(clientItem.sku) || itm.sku.indexOf(clientItem.sku + '-') === 0 /* bundle products */ - }) - - if (!serverItem) { - Logger.warn('No server item with sku ' + clientItem.sku + ' on stock.', 'cart')() - diffLog.items.push({ 'party': 'server', 'sku': clientItem.sku, 'status': 'no-item' }) - if (!dryRun) { - if (forceClientState || !config.cart.serverSyncCanRemoveLocalItems) { - const event = await _serverUpdateItem({ - cartServerToken: getters.getCartToken, - cartItem: { - sku: clientItem.parentSku && config.cart.setConfigurableProductOptions ? clientItem.parentSku : clientItem.sku, - qty: clientItem.qty, - product_option: clientItem.product_option - } - }) - _afterServerItemUpdated({ dispatch, commit }, event, clientItem) - serverCartUpdateRequired = true - totalsShouldBeRefreshed = true - } else { - dispatch('removeItem', { - product: clientItem - }) - } - } - } else if (serverItem.qty !== clientItem.qty) { - 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) { - if (forceClientState || !config.cart.serverSyncCanModifyLocalItems) { - const event = await _serverUpdateItem({ - cartServerToken: getters.getCartToken, - cartItem: { - sku: clientItem.parentSku && config.cart.setConfigurableProductOptions ? clientItem.parentSku : clientItem.sku, - qty: clientItem.qty, - item_id: serverItem.item_id, - quoteId: serverItem.quote_id, - product_option: clientItem.product_option - } - }) - _afterServerItemUpdated({ dispatch, commit }, event, clientItem) - totalsShouldBeRefreshed = true - serverCartUpdateRequired = true - } else { - await dispatch('updateItem', { - product: serverItem - }) - } - } - } else { - Logger.info('Server and client item with SKU ' + clientItem.sku + ' synced. Updating cart.', 'cart', 'cart')() - if (!dryRun) { - await dispatch('updateItem', { product: { sku: clientItem.sku, server_cart_id: serverItem.quote_id, server_item_id: serverItem.item_id, product_option: serverItem.product_option } }) - } - } - } - - for (const serverItem of serverItems) { - if (serverItem) { - const clientItem = clientItems.find((itm) => { - return itm.sku === serverItem.sku || serverItem.sku.indexOf(itm.sku + '-') === 0 /* bundle products */ - }) - if (!clientItem) { - Logger.info('No client item for' + serverItem.sku, 'cart')() - diffLog.items.push({ 'party': 'client', 'sku': serverItem.sku, 'status': 'no-item' }) - - if (!dryRun) { - if (forceClientState) { - Logger.info('Removing product from cart', 'cart', serverItem)() - Logger.log('Removing item' + serverItem.sku + serverItem.item_id, 'cart')() - serverCartUpdateRequired = true - totalsShouldBeRefreshed = true - const res = await _serverDeleteItem({ - cartServerToken: getters.getCartToken, - cartItem: { - sku: serverItem.sku, - item_id: serverItem.item_id, - quoteId: serverItem.quote_id - } - }) - diffLog.serverResponses.push({ 'status': res.resultCode, 'sku': serverItem.sku, 'result': res }) - } else { - const getServerCartItem = async () => { - try { - const actionOtions = await productActionOptions(serverItem) - - if (!actionOtions) { - return null - } - - const product = await dispatch('product/single', { options: actionOtions, assignDefaultVariant: true, setCurrentProduct: false, selectDefaultVariant: false }, { root: true }) - - if (!product) { - return null - } - - return { product: product, serverItem: serverItem } - } catch (err) { - return null - } - } - clientCartAddItems.push(getServerCartItem()) - } - } - } - } - } - - const resolvedCartItems = await Promise.all(clientCartAddItems) - const validCartItems = resolvedCartItems.filter(Boolean) - - if (validCartItems.length) { - totalsShouldBeRefreshed = true - clientCartUpdateRequired = true - cartHasItems = true - } - diffLog.items.push({ 'party': 'client', 'status': clientCartUpdateRequired ? 'update-required' : 'no-changes' }) - diffLog.items.push({ 'party': 'server', 'status': serverCartUpdateRequired ? 'update-required' : 'no-changes' }) - - for (const { product, serverItem } of validCartItems) { - product.server_item_id = serverItem.item_id - product.qty = serverItem.qty - product.server_cart_id = serverItem.quote_id - if (serverItem.product_option) { - product.product_option = serverItem.product_option - } - dispatch('addItem', { productToAdd: product, forceServerSilence: true }) - } - - if (!dryRun) { - if (totalsShouldBeRefreshed && cartHasItems) { - await dispatch('syncTotals') - } - commit(types.CART_SET_ITEMS_HASH, getters.getCurrentCartHash) // update the cart hash - } - Vue.prototype.$bus.$emit('servercart-after-diff', { diffLog: diffLog, serverItems: serverItems, clientItems: clientItems, dryRun: dryRun, event: event }) // send the difflog - Logger.info('Client/Server cart synchronised ', 'cart', diffLog)() - return diffLog - }, - toggleMicrocart ({ commit }) { - commit(types.CART_TOGGLE_MICROCART) - } -} - -export default actions diff --git a/core/modules/cart/store/actions/connectActions.ts b/core/modules/cart/store/actions/connectActions.ts new file mode 100644 index 000000000..d6ff2c907 --- /dev/null +++ b/core/modules/cart/store/actions/connectActions.ts @@ -0,0 +1,59 @@ +import * as types from '@vue-storefront/core/modules/cart/store/mutation-types' +import { Logger } from '@vue-storefront/core/lib/logger' +import config from 'config' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' +import { CartService } from '@vue-storefront/core/data-resolver' +import { createDiffLog } from '@vue-storefront/core/modules/cart/helpers' + +const connectActions = { + toggleMicrocart ({ commit }) { + commit(types.CART_TOGGLE_MICROCART) + }, + async clear ({ commit, dispatch, getters }, options = { recreateAndSyncCart: true }) { + await commit(types.CART_LOAD_CART, []) + if (options.recreateAndSyncCart && getters.isCartSyncEnabled) { + await commit(types.CART_LOAD_CART_SERVER_TOKEN, null) + 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 + } + }, + async disconnect ({ commit }) { + commit(types.CART_LOAD_CART_SERVER_TOKEN, null) + }, + async authorize ({ dispatch, getters }) { + const coupon = getters.getCoupon.code + const lastCartBypassTs = await StorageManager.get('user').getItem('last-cart-bypass-ts') + const timeBypassCart = config.orders.directBackendSync || (Date.now() - lastCartBypassTs) >= (1000 * 60 * 24) + + if (!config.cart.bypassCartLoaderForAuthorizedUsers || timeBypassCart) { + await dispatch('connect', { guestCart: false }) + + if (!getters.getCoupon) { + await dispatch('applyCoupon', coupon) + } + } + }, + async connect ({ getters, dispatch, commit }, { guestCart = false, forceClientState = false }) { + if (!getters.isCartSyncEnabled) return + const { result, resultCode } = await CartService.getCartToken(guestCart, forceClientState) + + if (resultCode === 200) { + Logger.info('Server cart token created.', 'cart', result)() + commit(types.CART_LOAD_CART_SERVER_TOKEN, result) + + return dispatch('sync', { forceClientState, dryRun: !config.cart.serverMergeByDefault }) + } + + if (resultCode === 401 && getters.bypassCounter < config.queues.maxCartBypassAttempts) { + Logger.log('Bypassing with guest cart' + getters.bypassCounter, 'cart')() + commit(types.CART_UPDATE_BYPASS_COUNTER, { counter: 1 }) + Logger.error(result, 'cart')() + return dispatch('connect', { guestCart: true }) + } + + Logger.warn('Cart sync is disabled by the config', 'cart')() + return createDiffLog() + } +} + +export default connectActions diff --git a/core/modules/cart/store/actions/couponActions.ts b/core/modules/cart/store/actions/couponActions.ts new file mode 100644 index 000000000..404c37e42 --- /dev/null +++ b/core/modules/cart/store/actions/couponActions.ts @@ -0,0 +1,26 @@ +import { CartService } from '@vue-storefront/core/data-resolver' + +const couponActions = { + async removeCoupon ({ getters, dispatch }) { + if (getters.canSyncTotals) { + const { result } = await CartService.removeCoupon() + + if (result) { + dispatch('syncTotals', { forceServerSync: true }) + return result + } + } + }, + async applyCoupon ({ getters, dispatch }, couponCode) { + if (couponCode && getters.canSyncTotals) { + const { result } = await CartService.applyCoupon(couponCode) + + if (result) { + dispatch('syncTotals', { forceServerSync: true }) + } + return result + } + } +} + +export default couponActions diff --git a/core/modules/cart/store/actions/index.ts b/core/modules/cart/store/actions/index.ts new file mode 100644 index 000000000..4f4590279 --- /dev/null +++ b/core/modules/cart/store/actions/index.ts @@ -0,0 +1,27 @@ + +import { ActionTree } from 'vuex' +import RootState from '@vue-storefront/core/types/RootState' +import CartState from '@vue-storefront/core/modules/cart/types/CartState' +import connectActions from './connectActions' +import couponActions from './couponActions' +import itemActions from './itemActions' +import mergeActions from './mergeActions'; +import methodsActions from './methodsActions' +import productActions from './productActions' +import quantityActions from './quantityActions' +import synchronizeActions from './synchronizeActions' +import totalsActions from './totalsActions' + +const actions: ActionTree = { + ...connectActions, + ...itemActions, + ...couponActions, + ...mergeActions, + ...methodsActions, + ...productActions, + ...quantityActions, + ...synchronizeActions, + ...totalsActions +} + +export default actions diff --git a/core/modules/cart/store/actions/itemActions.ts b/core/modules/cart/store/actions/itemActions.ts new file mode 100644 index 000000000..3cc36d2f0 --- /dev/null +++ b/core/modules/cart/store/actions/itemActions.ts @@ -0,0 +1,109 @@ +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, + validateProduct, + createDiffLog, + notifications +} from '@vue-storefront/core/modules/cart/helpers' +import { cartHooksExecutors } from './../../hooks' + +const itemActions = { + configureItem (context, { product, configuration }) { + const { commit, dispatch, getters } = context + const variant = configureProductAsync(context, { + product, + configuration, + selectDefaultVariant: false + }) + const itemWithSameSku = getters.getCartItems.find(item => item.sku === variant.sku) + + if (itemWithSameSku && product.sku !== variant.sku) { + Logger.debug('Item with the same sku detected', 'cart', { sku: itemWithSameSku.sku })() + commit(types.CART_DEL_ITEM, { product: itemWithSameSku }) + product.qty = parseInt(product.qty) + parseInt(itemWithSameSku.qty) + } + + commit(types.CART_UPD_ITEM_PROPS, { product: { ...product, ...variant } }) + + if (getters.isCartSyncEnabled && product.server_item_id) { + dispatch('sync', { forceClientState: true }) + } + }, + updateItem ({ commit }, { product }) { + commit(types.CART_UPD_ITEM_PROPS, { product }) + }, + getItem ({ getters }, { product }) { + return getters.getCartItems.find(p => productsEquals(p, product)) + }, + async addItem ({ dispatch, commit }, { productToAdd, forceServerSilence = false }) { + const { cartItem } = cartHooksExecutors.beforeAddToCart({ cartItem: productToAdd }) + commit(types.CART_ADDING_ITEM, { isAdding: true }) + const result = await dispatch('addItems', { productsToAdd: prepareProductsToAdd(cartItem), forceServerSilence }) + commit(types.CART_ADDING_ITEM, { isAdding: false }) + cartHooksExecutors.afterAddToCart(result) + return result + }, + async checkProductStatus ({ dispatch, getters }, { product }) { + const record = getters.getCartItems.find(p => productsEquals(p, product)) + const qty = record ? record.qty + 1 : (product.qty ? product.qty : 1) + + return dispatch('stock/queueCheck', { product, qty }, {root: true}) + }, + async addItems ({ commit, dispatch, getters }, { productsToAdd, forceServerSilence = false }) { + let productIndex = 0 + const diffLog = createDiffLog() + for (let product of productsToAdd) { + const errors = validateProduct(product) + diffLog.pushNotifications(notifications.createNotifications({ type: 'error', messages: errors })) + + if (errors.length === 0) { + const { status, onlineCheckTaskId } = await dispatch('checkProductStatus', { product }) + + if (status === 'volatile') { + diffLog.pushNotification(notifications.unsafeQuantity()) + } + if (status === 'out_of_stock') { + diffLog.pushNotification(notifications.outOfStock()) + } + + if (status === 'ok' || status === 'volatile') { + commit(types.CART_ADD_ITEM, { + product: { ...product, onlineStockCheckid: onlineCheckTaskId } + }) + } + if (productIndex === (productsToAdd.length - 1) && (!getters.isCartSyncEnabled || forceServerSilence)) { + diffLog.pushNotification(notifications.productAddedToCart()) + } + productIndex++ + } + } + if (getters.isCartSyncEnabled && getters.isCartConnected && !forceServerSilence) { + return dispatch('sync', { forceClientState: true }) + } + + return diffLog + }, + async removeItem ({ commit, dispatch, getters }, payload) { + const removeByParentSku = payload.product ? !!payload.removeByParentSku && payload.product.type_id !== 'bundle' : true + const product = payload.product || payload + const { cartItem } = cartHooksExecutors.beforeRemoveFromCart({ cartItem: product }) + + commit(types.CART_DEL_ITEM, { product: cartItem, removeByParentSku }) + + if (getters.isCartSyncEnabled && cartItem.server_item_id) { + const diffLog = await dispatch('sync', { forceClientState: true }) + cartHooksExecutors.afterRemoveFromCart(diffLog) + return diffLog + } + + const diffLog = createDiffLog() + .pushClientParty({ status: 'no-item', sku: product.sku }) + cartHooksExecutors.afterRemoveFromCart(diffLog) + return diffLog + } +} + +export default itemActions diff --git a/core/modules/cart/store/actions/mergeActions.ts b/core/modules/cart/store/actions/mergeActions.ts new file mode 100644 index 000000000..448d87f0a --- /dev/null +++ b/core/modules/cart/store/actions/mergeActions.ts @@ -0,0 +1,216 @@ +import * as types from '@vue-storefront/core/modules/cart/store/mutation-types' +import { Logger } from '@vue-storefront/core/lib/logger' +import config from 'config' +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' +import { CartService } from '@vue-storefront/core/data-resolver' +import { + productsEquals, + createDiffLog, + notifications, + createCartItemForUpdate +} from '@vue-storefront/core/modules/cart/helpers' +import CartItem from '@vue-storefront/core/modules/cart/types/CartItem'; +import { cartHooksExecutors } from './../../hooks' + +const mergeActions = { + async updateClientItem ({ dispatch }, { clientItem, serverItem }) { + const cartItem = clientItem === null ? await dispatch('getItem', serverItem) : clientItem + + if (!cartItem || typeof serverItem.item_id === 'undefined') return + + const product = { + server_item_id: serverItem.item_id, + sku: cartItem.sku, + server_cart_id: serverItem.quote_id, + prev_qty: cartItem.qty, + product_option: serverItem.product_option, + type_id: serverItem.product_type + } + + await dispatch('updateItem', { product }) + EventBus.$emit('cart-after-itemchanged', { item: cartItem }) + }, + async updateServerItem ({ getters, rootGetters, commit, dispatch }, { clientItem, serverItem, updateIds }) { + const diffLog = createDiffLog() + const cartItem = createCartItemForUpdate(clientItem, serverItem, updateIds) + const event = await CartService.updateItem(getters.getCartToken, cartItem) + const wasUpdatedSuccessfully = event.resultCode === 200 + Logger.debug('Cart item server sync' + event, 'cart')() + diffLog.pushServerResponse({ status: event.resultCode, sku: clientItem.sku, result: event }) + + if (!wasUpdatedSuccessfully && !serverItem) { + commit(types.CART_DEL_ITEM, { product: clientItem, removeByParentSku: false }) + return diffLog + } + + if (!wasUpdatedSuccessfully && clientItem.item_id) { + await dispatch('restoreQuantity', { cartItem, clientItem }) + return diffLog + } + + if (!wasUpdatedSuccessfully) { + Logger.warn('Removing product from cart', 'cart', clientItem)() + commit(types.CART_DEL_NON_CONFIRMED_ITEM, { product: clientItem }) + return diffLog + } + + if (!rootGetters['checkout/isUserInCheckout']) { + const isThisNewItemAddedToTheCart = (!clientItem || !clientItem.server_item_id) + diffLog.pushNotification( + isThisNewItemAddedToTheCart ? notifications.productAddedToCart() : notifications.productQuantityUpdated() + ) + } + + await dispatch('updateClientItem', { clientItem, serverItem: event.result }) + + return diffLog + }, + async synchronizeServerItem ({ dispatch }, { serverItem, clientItem, forceClientState, dryRun }) { + const diffLog = createDiffLog() + + if (!serverItem) { + Logger.warn('No server item with sku ' + clientItem.sku + ' on stock.', 'cart')() + diffLog.pushServerParty({ sku: clientItem.sku, status: 'no-item' }) + + if (dryRun) return diffLog + if (forceClientState || !config.cart.serverSyncCanRemoveLocalItems) { + const updateServerItemDiffLog = await dispatch('updateServerItem', { clientItem, serverItem, updateIds: false }) + return diffLog.merge(updateServerItemDiffLog) + } + + await dispatch('removeItem', { product: clientItem }) + return diffLog + } + + if (serverItem.qty !== clientItem.qty) { + Logger.log('Wrong qty for ' + clientItem.sku, clientItem.qty, serverItem.qty)() + diffLog.pushServerParty({ sku: clientItem.sku, status: 'wrong-qty', 'client-qty': clientItem.qty, 'server-qty': serverItem.qty }) + if (dryRun) return diffLog + if (forceClientState || !config.cart.serverSyncCanModifyLocalItems) { + const updateServerItemDiffLog = await dispatch('updateServerItem', { clientItem, serverItem, updateIds: true }) + + return diffLog.merge(updateServerItemDiffLog) + } + + await dispatch('updateItem', { product: serverItem }) + } + + return diffLog + }, + async mergeClientItem ({ dispatch }, { clientItem, serverItems, forceClientState, dryRun }) { + const serverItem = serverItems.find(itm => productsEquals(itm, clientItem)) + const diffLog = await dispatch('synchronizeServerItem', { serverItem, clientItem, forceClientState, dryRun }) + + if (!diffLog.isEmpty()) return diffLog + + Logger.info('Server and client item with SKU ' + clientItem.sku + ' synced. Updating cart.', 'cart', 'cart')() + if (!dryRun) { + const product = { + sku: clientItem.sku, + server_cart_id: serverItem.quote_id, + server_item_id: serverItem.item_id, + product_option: serverItem.product_option, + type_id: serverItem.product_type + } + + await dispatch('updateItem', { product }) + } + + return diffLog + }, + async mergeClientItems ({ dispatch }, { clientItems, serverItems, forceClientState, dryRun }) { + const diffLog = createDiffLog() + + for (const clientItem of clientItems) { + try { + const mergeClientItemDiffLog = await dispatch('mergeClientItem', { clientItem, serverItems, forceClientState, dryRun }) + diffLog.merge(mergeClientItemDiffLog) + } catch (e) { + Logger.debug('Problem syncing clientItem', 'cart', clientItem)() + } + } + + return diffLog + }, + async mergeServerItem ({ dispatch, getters }, { clientItems, serverItem, forceClientState, dryRun }) { + const diffLog = createDiffLog() + const clientItem = clientItems.find(itm => productsEquals(itm, serverItem)) + if (clientItem) return diffLog + Logger.info('No client item for' + serverItem.sku, 'cart')() + diffLog.pushClientParty({ sku: serverItem.sku, status: 'no-item' }) + if (dryRun) return diffLog + + if (forceClientState) { + Logger.info('Removing product from cart', 'cart', serverItem)() + Logger.log('Removing item' + serverItem.sku + serverItem.item_id, 'cart')() + const cartItem = { + sku: serverItem.sku, + item_id: serverItem.item_id, + quoteId: serverItem.quote_id + } as any as CartItem + + const resp = await CartService.deleteItem(getters.getCartToken, cartItem) + return diffLog.pushServerResponse({ status: resp.resultCode, sku: serverItem.sku, result: resp }) + } + + const productToAdd = await dispatch('getProductVariant', { serverItem }) + + if (productToAdd) { + dispatch('addItem', { productToAdd, forceServerSilence: true }) + Logger.debug('Product variant for given serverItem has not found', 'cart', serverItem)() + } + + return diffLog + }, + async mergeServerItems ({ dispatch }, { serverItems, clientItems, forceClientState, dryRun }) { + const diffLog = createDiffLog() + const definedServerItems = serverItems.filter(serverItem => serverItem) + + for (const serverItem of definedServerItems) { + try { + const mergeServerItemDiffLog = await dispatch('mergeServerItem', { clientItems, serverItem, forceClientState, dryRun }) + diffLog.merge(mergeServerItemDiffLog) + } catch (e) { + Logger.debug('Problem syncing serverItem', 'cart', serverItem)() + } + } + + return diffLog + }, + async updateTotalsAfterMerge ({ dispatch, getters, commit }, { clientItems, dryRun }) { + if (dryRun) return + + if (getters.isTotalsSyncRequired && clientItems.length > 0) { + await dispatch('syncTotals') + } + + commit(types.CART_SET_ITEMS_HASH, getters.getCurrentCartHash) + }, + async merge ({ getters, dispatch }, { serverItems, clientItems, dryRun = false, forceClientState = false }) { + const hookResult = cartHooksExecutors.beforeSync({ clientItems, serverItems }) + + const diffLog = createDiffLog() + const mergeParameters = { + clientItems: hookResult.clientItems, + serverItems: hookResult.serverItems, + forceClientState, + dryRun + } + const mergeClientItemsDiffLog = await dispatch('mergeClientItems', mergeParameters) + const mergeServerItemsDiffLog = await dispatch('mergeServerItems', mergeParameters) + dispatch('updateTotalsAfterMerge', { clientItems, dryRun }) + + diffLog + .merge(mergeClientItemsDiffLog) + .merge(mergeServerItemsDiffLog) + .pushClientParty({ status: getters.isCartHashChanged ? 'update-required' : 'no-changes' }) + .pushServerParty({ status: getters.isTotalsSyncRequired ? 'update-required' : 'no-changes' }) + + EventBus.$emit('servercart-after-diff', { diffLog: diffLog, serverItems: hookResult.serverItem, clientItems: hookResult.clientItems, dryRun: dryRun }) + Logger.info('Client/Server cart synchronised ', 'cart', diffLog)() + + return diffLog + } +} + +export default mergeActions diff --git a/core/modules/cart/store/actions/methodsActions.ts b/core/modules/cart/store/actions/methodsActions.ts new file mode 100644 index 000000000..5eb415925 --- /dev/null +++ b/core/modules/cart/store/actions/methodsActions.ts @@ -0,0 +1,93 @@ +import * as types from '@vue-storefront/core/modules/cart/store/mutation-types' +import { currentStoreView } from '@vue-storefront/core/lib/multistore' +import { Logger } from '@vue-storefront/core/lib/logger' +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' +import { CartService } from '@vue-storefront/core/data-resolver' +import { preparePaymentMethodsToSync, createOrderData, createShippingInfoData } from '@vue-storefront/core/modules/cart/helpers' +import PaymentMethod from '../../types/PaymentMethod' + +const methodsActions = { + async pullMethods ({ getters, dispatch }, { forceServerSync }) { + if (getters.isTotalsSyncRequired || forceServerSync) { + await dispatch('syncShippingMethods', { forceServerSync }) + await dispatch('syncPaymentMethods', { forceServerSync }) + } else { + Logger.debug('Skipping payment & shipping methods update as cart has not been changed', 'cart')() + } + }, + async setDefaultCheckoutMethods ({ getters, rootGetters, commit }) { + if (!getters.getShippingMethodCode) { + commit(types.CART_UPD_SHIPPING, rootGetters['checkout/getDefaultShippingMethod']) + } + + if (!getters.getPaymentMethodCode) { + commit(types.CART_UPD_PAYMENT, rootGetters['checkout/getDefaultPaymentMethod']) + } + }, + async syncPaymentMethods ({ getters, rootGetters, dispatch }, { forceServerSync = false }) { + if (getters.canUpdateMethods && (getters.isTotalsSyncRequired || forceServerSync)) { + Logger.debug('Refreshing payment methods', 'cart')() + let backendPaymentMethods: PaymentMethod[] + + const paymentDetails = rootGetters['checkout/getPaymentDetails'] + if (paymentDetails.country) { + // use shipping info endpoint to get payment methods using billing address + const shippingMethodsData = createOrderData({ + shippingDetails: rootGetters['checkout/getShippingDetails'], + shippingMethods: rootGetters['checkout/getShippingMethods'], + paymentMethods: rootGetters['checkout/getPaymentMethods'], + paymentDetails: paymentDetails + }) + + if (shippingMethodsData.country) { + const { result } = await CartService.setShippingInfo(createShippingInfoData(shippingMethodsData)) + backendPaymentMethods = result.payment_methods || [] + } + } + if (!backendPaymentMethods || backendPaymentMethods.length === 0) { + const { result } = await CartService.getPaymentMethods() + backendPaymentMethods = result + } + + const { uniqueBackendMethods, paymentMethods } = preparePaymentMethodsToSync( + backendPaymentMethods, + rootGetters['checkout/getNotServerPaymentMethods'] + ) + await dispatch('checkout/replacePaymentMethods', paymentMethods, { root: true }) + EventBus.$emit('set-unique-payment-methods', uniqueBackendMethods) + } else { + Logger.debug('Payment methods does not need to be updated', 'cart')() + } + }, + async updateShippingMethods ({ dispatch }, { shippingMethods }) { + if (shippingMethods.length > 0) { + const newShippingMethods = shippingMethods.map(method => ({ ...method, is_server_method: true })) + await dispatch('checkout/replaceShippingMethods', newShippingMethods, { root: true }) + } + }, + async syncShippingMethods ({ getters, rootGetters, dispatch }, { forceServerSync = false }) { + if (getters.canUpdateMethods && (getters.isTotalsSyncRequired || forceServerSync)) { + const storeView = currentStoreView() + Logger.debug('Refreshing shipping methods', 'cart')() + const shippingDetails = rootGetters['checkout/getShippingDetails'] + + // build address data with what we have + const address = (shippingDetails) ? { + region: shippingDetails.state, + region_id: shippingDetails.region_id ? shippingDetails.region_id : 0, + country_id: shippingDetails.country, + street: [shippingDetails.streetAddress1, shippingDetails.streetAddress2], + postcode: shippingDetails.zipCode, + city: shippingDetails.city, + region_code: shippingDetails.region_code ? shippingDetails.region_code : '' + } : {country_id: storeView.tax.defaultCountry} + + const { result } = await CartService.getShippingMethods(address) + await dispatch('updateShippingMethods', { shippingMethods: result }) + } else { + Logger.debug('Shipping methods does not need to be updated', 'cart')() + } + } +} + +export default methodsActions diff --git a/core/modules/cart/store/actions/productActions.ts b/core/modules/cart/store/actions/productActions.ts new file mode 100644 index 000000000..b698c6438 --- /dev/null +++ b/core/modules/cart/store/actions/productActions.ts @@ -0,0 +1,34 @@ +import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' + +const productActions = { + async findProductOption ({ dispatch }, { serverItem }) { + if (serverItem.product_type === 'configurable') { + 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 }) + + return items.length >= 1 ? { sku: items[0].sku, childSku: serverItem.sku } : null + } + + return { sku: serverItem.sku } + }, + 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 }) + + return { + ...singleProduct, + server_item_id: serverItem.item_id, + qty: serverItem.qty, + server_cart_id: serverItem.quote_id, + product_option: serverItem.product_option || singleProduct.product_option + } + } catch (e) { + return null + } + } +} + +export default productActions diff --git a/core/modules/cart/store/actions/quantityActions.ts b/core/modules/cart/store/actions/quantityActions.ts new file mode 100644 index 000000000..cd670cb64 --- /dev/null +++ b/core/modules/cart/store/actions/quantityActions.ts @@ -0,0 +1,30 @@ +import * as types from '@vue-storefront/core/modules/cart/store/mutation-types' +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' +import { Logger } from '@vue-storefront/core/lib/logger' +import { createDiffLog } from '@vue-storefront/core/modules/cart/helpers' + +const quantityActions = { + async restoreQuantity ({ dispatch }, { cartItem, clientItem }) { + const currentCartItem = await dispatch('getItem', clientItem) + if (currentCartItem) { + Logger.log('Restoring qty after error' + clientItem.sku + currentCartItem.prev_qty, 'cart')() + if (cartItem.prev_qty > 0) { + dispatch('updateItem', { product: { qty: currentCartItem.prev_qty } }) + EventBus.$emit('cart-after-itemchanged', { item: currentCartItem }) + } else { + dispatch('removeItem', { product: currentCartItem, removeByParentSku: false }) + } + } + }, + async updateQuantity ({ commit, dispatch, getters }, { product, qty, forceServerSilence = false }) { + commit(types.CART_UPD_ITEM, { product, qty }) + if (getters.isCartSyncEnabled && product.server_item_id && !forceServerSilence) { + return dispatch('sync', { forceClientState: true }) + } + + return createDiffLog() + .pushClientParty({ status: 'wrong-qty', sku: product.sku, 'client-qty': qty }) + } +} + +export default quantityActions diff --git a/core/modules/cart/store/actions/synchronizeActions.ts b/core/modules/cart/store/actions/synchronizeActions.ts new file mode 100644 index 000000000..fc1fb9210 --- /dev/null +++ b/core/modules/cart/store/actions/synchronizeActions.ts @@ -0,0 +1,106 @@ +import * as types from '@vue-storefront/core/modules/cart/store/mutation-types' +import { Logger } from '@vue-storefront/core/lib/logger' +import { isServer } from '@vue-storefront/core/helpers' +import config from 'config' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' +import { CartService } from '@vue-storefront/core/data-resolver' +import { createDiffLog } from '@vue-storefront/core/modules/cart/helpers' +import i18n from '@vue-storefront/i18n' +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' +import { cartHooksExecutors } from '../../hooks' + +const synchronizeActions = { + async load ({ commit, dispatch }, { forceClientState = false }: {forceClientState?: boolean} = {}) { + if (isServer) return + + dispatch('setDefaultCheckoutMethods') + const storedItems = await StorageManager.get('cart').getItem('current-cart') + commit(types.CART_LOAD_CART, storedItems) + dispatch('synchronizeCart', { forceClientState }) + + cartHooksExecutors.afterLoad(storedItems) + }, + async synchronizeCart ({ commit, dispatch }, { forceClientState }) { + const { synchronize, serverMergeByDefault } = config.cart + if (!synchronize) return + const cartStorage = StorageManager.get('cart') + const token = await cartStorage.getItem('current-cart-token') + const hash = await cartStorage.getItem('current-cart-hash') + + if (hash) { + commit(types.CART_SET_ITEMS_HASH, hash) + Logger.info('Cart hash received from cache.', 'cache', hash)() + } + if (token) { + commit(types.CART_LOAD_CART_SERVER_TOKEN, token) + Logger.info('Cart token received from cache.', 'cache', token)() + Logger.info('Syncing cart with the server.', 'cart')() + dispatch('sync', { forceClientState, dryRun: !serverMergeByDefault }) + } else { + Logger.info('Creating server cart token', 'cart')() + await dispatch('connect', { guestCart: false }) + } + }, + /** @deprecated backward compatibility only */ + async serverPull ({ dispatch }, { forceClientState = false, dryRun = false }) { + Logger.warn('The "cart/serverPull" action is deprecated and will not be supported with the Vue Storefront 1.11', 'cart')() + return dispatch('sync', { forceClientState, dryRun }) + }, + async sync ({ getters, rootGetters, commit, dispatch, state }, { forceClientState = false, dryRun = false }) { + const shouldUpdateClientState = rootGetters['checkout/isUserInCheckout'] || forceClientState + const { getCartItems, canUpdateMethods, isSyncRequired, bypassCounter } = getters + if (!canUpdateMethods || !isSyncRequired) return createDiffLog() + commit(types.CART_SET_SYNC) + const { result, resultCode } = await CartService.getItems() + const { serverItems, clientItems } = cartHooksExecutors.beforeSync({ clientItems: getCartItems, serverItems: result }) + + if (resultCode === 200) { + const diffLog = await dispatch('merge', { + dryRun, + serverItems, + clientItems, + forceClientState: shouldUpdateClientState + }) + cartHooksExecutors.afterSync(diffLog) + return diffLog + } + + if (bypassCounter < config.queues.maxCartBypassAttempts) { + Logger.log('Bypassing with guest cart' + bypassCounter, 'cart')() + commit(types.CART_UPDATE_BYPASS_COUNTER, { counter: 1 }) + await dispatch('connect', { guestCart: true }) + } + + Logger.error(result, 'cart') + cartHooksExecutors.afterSync(result) + return createDiffLog() + }, + async stockSync ({ dispatch, commit }, stockTask) { + const product = { sku: stockTask.product_sku } + + const cartItem = await dispatch('getItem', { product }) + + if (!cartItem || stockTask.result.code === 'ENOTFOUND') return + + if (!stockTask.result.is_in_stock) { + if (!config.stock.allowOutOfStockInCart && !config.cart.synchronize) { + Logger.log('Removing product from cart' + stockTask.product_sku, 'stock')() + commit(types.CART_DEL_ITEM, { product: { sku: stockTask.product_sku } }, { root: true }) + return + } + + dispatch('updateItem', { + product: { errors: { stock: i18n.t('Out of the stock!') }, sku: stockTask.product_sku, is_in_stock: false } + }) + + return + } + + dispatch('updateItem', { + product: { info: { stock: i18n.t('In stock!') }, sku: stockTask.product_sku, is_in_stock: true } + }) + EventBus.$emit('cart-after-itemchanged', { item: cartItem }) + } +} + +export default synchronizeActions diff --git a/core/modules/cart/store/actions/totalsActions.ts b/core/modules/cart/store/actions/totalsActions.ts new file mode 100644 index 000000000..0e0ea350d --- /dev/null +++ b/core/modules/cart/store/actions/totalsActions.ts @@ -0,0 +1,74 @@ +import * as types from '@vue-storefront/core/modules/cart/store/mutation-types' +import { Logger } from '@vue-storefront/core/lib/logger' +import { CartService } from '@vue-storefront/core/data-resolver' +import { + prepareShippingInfoForUpdateTotals, + createOrderData, + createShippingInfoData +} from '@vue-storefront/core/modules/cart/helpers' + +const totalsActions = { + async getTotals (context, { addressInformation, hasShippingInformation }) { + if (hasShippingInformation) { + return CartService.setShippingInfo(addressInformation) + } + + return CartService.getTotals() + }, + async overrideServerTotals ({ commit, getters, dispatch }, { addressInformation, hasShippingInformation }) { + const { resultCode, result } = await dispatch('getTotals', { addressInformation, hasShippingInformation }) + + if (resultCode === 200) { + const totals = result.totals || result + Logger.info('Overriding server totals. ', 'cart', totals)() + const itemsAfterTotal = prepareShippingInfoForUpdateTotals(totals.items) + + for (let key of Object.keys(itemsAfterTotal)) { + const item = itemsAfterTotal[key] + const product = { server_item_id: item.item_id, totals: item, qty: item.qty } + await dispatch('updateItem', { product }) + } + + commit(types.CART_UPD_TOTALS, { itemsAfterTotal, totals, platformTotalSegments: totals.total_segments }) + commit(types.CART_SET_TOTALS_SYNC) + + // we received payment methods as a result of this call, updating state + if (result.payment_methods && getters.canUpdateMethods) { + const backendPaymentMethods = result.payment_methods.map(method => ({ ...method, is_server_method: true })) + dispatch('checkout/replacePaymentMethods', backendPaymentMethods, { root: true }) + } + + return + } + + Logger.error(result, 'cart')() + }, + async syncTotals ({ dispatch, getters, rootGetters }, payload: { forceServerSync: boolean, methodsData?: any } = { forceServerSync: false, methodsData: null }) { + const methodsData = payload ? payload.methodsData : null + await dispatch('pullMethods', { forceServerSync: payload.forceServerSync }) + + if (getters.canSyncTotals && (getters.isTotalsSyncRequired || payload.forceServerSync)) { + const shippingMethodsData = methodsData || createOrderData({ + shippingDetails: rootGetters['checkout/getShippingDetails'], + shippingMethods: rootGetters['checkout/getShippingMethods'], + paymentMethods: rootGetters['checkout/getPaymentMethods'], + paymentDetails: rootGetters['checkout/getPaymentDetails'] + }) + + if (shippingMethodsData.country) { + return dispatch('overrideServerTotals', { + hasShippingInformation: shippingMethodsData.method_code || shippingMethodsData.carrier_code, + addressInformation: createShippingInfoData(shippingMethodsData) + }) + } + + Logger.error('Please do set the tax.defaultCountry in order to calculate totals', 'cart')() + } + }, + async refreshTotals ({ dispatch }, payload) { + Logger.warn('The "cart/refreshTotals" action is deprecated and will not be supported with the Vue Storefront 1.11', 'cart')() + return dispatch('syncTotals', payload) + } +} + +export default totalsActions diff --git a/core/modules/cart/store/getters.ts b/core/modules/cart/store/getters.ts index 4fd87f72a..d88157147 100644 --- a/core/modules/cart/store/getters.ts +++ b/core/modules/cart/store/getters.ts @@ -1,133 +1,45 @@ import { GetterTree } from 'vuex' import sumBy from 'lodash-es/sumBy' -import i18n from '@vue-storefront/i18n' import CartState from '../types/CartState' import RootState from '@vue-storefront/core/types/RootState' import AppliedCoupon from '../types/AppliedCoupon' import { onlineHelper, isServer, calcItemsHmac } from '@vue-storefront/core/helpers' +import { calculateTotals } from '@vue-storefront/core/modules/cart/helpers' import config from 'config' -import { Logger } from '@vue-storefront/core/lib/logger' const getters: GetterTree = { - getCartToken (state) { - return state.cartServerToken - }, - getLastSyncDate (state) { - return state.cartServerLastSyncDate - }, - getLastTotalsSyncDate (state) { - return state.cartServerLastTotalsSyncDate - }, - getShippingMethod (state) { - return state.shipping - }, - getPaymentMethod (state) { - return state.payment - }, - getLastCartHash (state) { - return state.cartItemsHash - }, - getCurrentCartHash (state) { - return calcItemsHmac(state.cartItems, state.cartServerToken) - }, - isCartHashChanged (state, getters) { - return getters.getCurrentCartHash !== state.cartItemsHash - }, - isSyncRequired (state, getters) { - return !state.cartItemsHash || (getters.getCurrentCartHash !== state.cartItemsHash) || !state.cartServerLastSyncDate // first load - never synced - }, - isTotalsSyncRequired (state, getters) { - return !state.cartItemsHash || (getters.getCurrentCartHash !== state.cartItemsHash) || !state.cartServerLastTotalsSyncDate // first load - never synced - }, - isCartHashEmtpyOrChanged (state, getters) { - return !state.cartItemsHash || (getters.getCurrentCartHash !== state.cartItemsHash) - }, - getCartItems (state) { - return state.cartItems - }, - isTotalsSyncEnabled (state) { - return config.cart.synchronize_totals && onlineHelper.isOnline && !isServer - }, - isCartConnected (state) { - return !!state.cartServerToken - }, - isCartSyncEnabled (state) { - return config.cart.synchronize && onlineHelper.isOnline && !isServer - }, - getTotals (state) { - if (state.platformTotalSegments && onlineHelper.isOnline) { - return state.platformTotalSegments - } else { - let shipping = state.shipping instanceof Array ? state.shipping[0] : state.shipping - let payment = state.payment instanceof Array ? state.payment[0] : state.payment - const totalsArray = [ - { - code: 'subtotalInclTax', - title: i18n.t('Subtotal incl. tax'), - value: sumBy(state.cartItems, (p) => { - return p.qty * p.priceInclTax - }) - }, - { - code: 'grand_total', - title: i18n.t('Grand total'), - value: sumBy(state.cartItems, (p) => { - return p.qty * p.priceInclTax + (shipping ? shipping.price_incl_tax : 0) - }) - } - ] - if (payment) { - totalsArray.push({ - code: 'payment', - title: i18n.t(payment.title), - value: payment.costInclTax - }) - } - if (shipping) { - totalsArray.push({ - code: 'shipping', - title: i18n.t(shipping.method_title), - value: shipping.price_incl_tax - }) - } - return totalsArray - } - }, - getItemsTotalQuantity (state, getters, rootStore) { - if (config.cart.minicartCountType === 'items') { - return state.cartItems.length - } + getCartToken: state => state.cartServerToken, + getLastSyncDate: state => state.cartServerLastSyncDate, + getLastTotalsSyncDate: state => state.cartServerLastTotalsSyncDate, + getShippingMethod: state => state.shipping, + getPaymentMethod: state => state.payment, + getLastCartHash: state => state.cartItemsHash, + getCurrentCartHash: state => calcItemsHmac(state.cartItems, state.cartServerToken), + isCartHashChanged: (state, getters) => getters.getCurrentCartHash !== state.cartItemsHash, + isSyncRequired: (state, getters) => getters.isCartHashEmptyOrChanged || !state.cartServerLastSyncDate, + isTotalsSyncRequired: (state, getters) => getters.isCartHashEmptyOrChanged || !state.cartServerLastTotalsSyncDate, + isCartHashEmptyOrChanged: (state, getters) => !state.cartItemsHash || getters.isCartHashChanged, + getCartItems: state => state.cartItems, + isTotalsSyncEnabled: () => config.cart.synchronize_totals && onlineHelper.isOnline && !isServer, + isCartConnected: state => !!state.cartServerToken, + isCartSyncEnabled: () => config.cart.synchronize && onlineHelper.isOnline && !isServer, + getFirstShippingMethod: state => state.shipping instanceof Array ? state.shipping[0] : state.shipping, + getFirstPaymentMethod: state => state.payment instanceof Array ? state.payment[0] : state.payment, + getTotals: ({ cartItems, platformTotalSegments }, getters) => + (platformTotalSegments && onlineHelper.isOnline) ? platformTotalSegments : calculateTotals(getters.getFirstShippingMethod, getters.getFirstPaymentMethod, cartItems), + 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'), + canUpdateMethods: (state, getters) => getters.isCartSyncEnabled && getters.isCartConnected, + canSyncTotals: (state, getters) => getters.isTotalsSyncEnabled && getters.isCartConnected, + isCartEmpty: state => state.cartItems.length === 0, + bypassCounter: state => state.connectBypassCount, + getShippingMethodCode: state => state.shipping && state.shipping.method_code, + getPaymentMethodCode: state => state.payment && state.payment.code, + getIsAdding: state => state.isAddingToCart, + getIsMicroCartOpen: state => state.isMicrocartOpen - return sumBy(state.cartItems, (p) => { - return p.qty - }) - }, - getCoupon (state): AppliedCoupon | false { - if (!(state.platformTotals && state.platformTotals.hasOwnProperty('coupon_code'))) { - return false - } - return { - code: state.platformTotals.coupon_code, - discount: state.platformTotals.discount_amount - } - }, - /** @deprecated */ - coupon (state) { - Logger.error('The getter cart.coupon has been deprecated please change to cart.getters.getCoupon()')() - }, - /** @deprecated */ - totalQuantity (state) { - Logger.error('The getter cart.totalQuantity has been deprecated please change to cart.getters.getItemsTotalQuantity()')() - }, - /** @deprecated */ - totals (state) { - Logger.error('The getter cart.totals has been deprecated please change to cart.getters.getTotals()')() - }, - isVirtualCart (state) { - return state.cartItems.every((itm) => { - return itm.type_id === 'downloadable' || itm.type_id === 'virtual' // check for downloadable & virtual products - }) - } } export default getters diff --git a/core/modules/cart/store/index.ts b/core/modules/cart/store/index.ts index cbb78a58a..34ea8c337 100644 --- a/core/modules/cart/store/index.ts +++ b/core/modules/cart/store/index.ts @@ -4,7 +4,7 @@ import getters from './getters' import mutations from './mutations' import CartState from '../types/CartState' -export const module: Module = { +export const cartStore: Module = { namespaced: true, state: { isMicrocartOpen: false, @@ -18,7 +18,9 @@ export const module: Module = { cartItemsHash: '', cartServerLastSyncDate: 0, cartServerLastTotalsSyncDate: 0, - cartItems: [] // TODO: check if it's properly namespaced + cartItems: [], // TODO: check if it's properly namespaced + connectBypassCount: 0, + isAddingToCart: false }, getters, actions, diff --git a/core/modules/cart/store/mutation-types.ts b/core/modules/cart/store/mutation-types.ts index 5749c0658..5831ea5bb 100644 --- a/core/modules/cart/store/mutation-types.ts +++ b/core/modules/cart/store/mutation-types.ts @@ -14,3 +14,5 @@ export const CART_UPD_TOTALS = SN_CART + '/UPD_TOTALS' export const CART_LOAD_CART_SERVER_TOKEN = SN_CART + '/SRV_TOKEN' export const CART_UPD_PAYMENT = SN_CART + '/UPD_PAYMENT' export const CART_TOGGLE_MICROCART = SN_CART + '/TOGGLE_MICROCART' +export const CART_UPDATE_BYPASS_COUNTER = SN_CART + '/UPD_BYPASS_COUNTER' +export const CART_ADDING_ITEM = SN_CART + '/UPD_ADDING_ITEM' diff --git a/core/modules/cart/store/mutations.ts b/core/modules/cart/store/mutations.ts index c3bc3a8c6..eb016bb08 100644 --- a/core/modules/cart/store/mutations.ts +++ b/core/modules/cart/store/mutations.ts @@ -1,9 +1,9 @@ -import Vue from 'vue' import { MutationTree } from 'vuex' import * as types from './mutation-types' import CartState from '../types/CartState' import config from 'config' -import { calcItemsHmac } from '@vue-storefront/core/helpers' +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' +import productsEquals from './../helpers/productsEquals' const mutations: MutationTree = { /** @@ -11,16 +11,16 @@ const mutations: MutationTree = { * @param {Object} product data format for products is described in /doc/ElasticSearch data formats.md */ [types.CART_ADD_ITEM] (state, { product }) { - const record = state.cartItems.find(p => p.sku === product.sku) + const record = state.cartItems.find(p => productsEquals(p, product)) if (!record) { let item = { ...product, qty: parseInt(product.qty ? product.qty : 1) } - Vue.prototype.$bus.$emit('cart-before-add', { product: item }) + EventBus.$emit('cart-before-add', { product: item }) state.cartItems.push(item) } else { - Vue.prototype.$bus.$emit('cart-before-update', { product: record }) + EventBus.$emit('cart-before-update', { product: record }) record.qty += parseInt((product.qty ? product.qty : 1)) } }, @@ -34,30 +34,30 @@ const mutations: MutationTree = { state.cartServerLastTotalsSyncDate = new Date().getTime() }, [types.CART_DEL_ITEM] (state, { product, removeByParentSku = true }) { - Vue.prototype.$bus.$emit('cart-before-delete', { items: state.cartItems }) - state.cartItems = state.cartItems.filter(p => p.sku !== product.sku && (p.parentSku !== product.sku || removeByParentSku === false)) - Vue.prototype.$bus.$emit('cart-after-delete', { items: state.cartItems }) + EventBus.$emit('cart-before-delete', { items: state.cartItems }) + state.cartItems = state.cartItems.filter(p => !productsEquals(p, product) && (p.parentSku !== product.sku || removeByParentSku === false)) + EventBus.$emit('cart-after-delete', { items: state.cartItems }) }, [types.CART_DEL_NON_CONFIRMED_ITEM] (state, { product, removeByParentSku = true }) { - Vue.prototype.$bus.$emit('cart-before-delete', { items: state.cartItems }) - state.cartItems = state.cartItems.filter(p => (p.sku !== product.sku && (p.parentSku !== product.sku || removeByParentSku === false)) || p.server_item_id/* it's confirmed if server_item_id is set */) - Vue.prototype.$bus.$emit('cart-after-delete', { items: state.cartItems }) + EventBus.$emit('cart-before-delete', { items: state.cartItems }) + state.cartItems = state.cartItems.filter(p => (!productsEquals(p, product) && (p.parentSku !== product.sku || removeByParentSku === false)) || p.server_item_id/* it's confirmed if server_item_id is set */) + EventBus.$emit('cart-after-delete', { items: state.cartItems }) }, [types.CART_UPD_ITEM] (state, { product, qty }) { - const record = state.cartItems.find(p => p.sku === product.sku) + const record = state.cartItems.find(p => productsEquals(p, product)) if (record) { - Vue.prototype.$bus.$emit('cart-before-update', { product: record }) + EventBus.$emit('cart-before-update', { product: record }) record.qty = parseInt(qty) - Vue.prototype.$bus.$emit('cart-after-update', { product: record }) + EventBus.$emit('cart-after-update', { product: record }) } }, [types.CART_UPD_ITEM_PROPS] (state, { product }) { - let record = state.cartItems.find(p => (p.sku === product.sku || (p.server_item_id && p.server_item_id === product.server_item_id))) + let record = state.cartItems.find(p => (productsEquals(p, product) || (p.server_item_id && p.server_item_id === product.server_item_id))) if (record) { - Vue.prototype.$bus.$emit('cart-before-itemchanged', { item: record }) + EventBus.$emit('cart-before-itemchanged', { item: record }) record = Object.assign(record, product) - Vue.prototype.$bus.$emit('cart-after-itemchanged', { item: record }) + EventBus.$emit('cart-after-itemchanged', { item: record }) } }, [types.CART_UPD_SHIPPING] (state, shippingMethod) { @@ -67,10 +67,10 @@ const mutations: MutationTree = { state.cartItems = storedItems || [] state.cartIsLoaded = true - // Vue.prototype.$bus.$emit('order/PROCESS_QUEUE', { config: config }) // process checkout queue - Vue.prototype.$bus.$emit('sync/PROCESS_QUEUE', { config }) // process checkout queue - Vue.prototype.$bus.$emit('application-after-loaded') - Vue.prototype.$bus.$emit('cart-after-loaded') + // EventBus.$emit('order/PROCESS_QUEUE', { config: config }) // process checkout queue + EventBus.$emit('sync/PROCESS_QUEUE', { config }) // process checkout queue + EventBus.$emit('application-after-loaded') + EventBus.$emit('cart-after-loaded') }, [types.CART_LOAD_CART_SERVER_TOKEN] (state, token) { state.cartServerToken = token @@ -79,13 +79,19 @@ const mutations: MutationTree = { state.itemsAfterPlatformTotals = itemsAfterTotals state.platformTotals = totals state.platformTotalSegments = platformTotalSegments - Vue.prototype.$bus.$emit('cart-after-updatetotals', { platformTotals: totals, platformTotalSegments: platformTotalSegments }) + EventBus.$emit('cart-after-updatetotals', { platformTotals: totals, platformTotalSegments: platformTotalSegments }) }, [types.CART_UPD_PAYMENT] (state, paymentMethod) { state.payment = paymentMethod }, [types.CART_TOGGLE_MICROCART] (state) { state.isMicrocartOpen = !state.isMicrocartOpen + }, + [types.CART_UPDATE_BYPASS_COUNTER] (state, { counter }) { + state.connectBypassCount = state.connectBypassCount + counter + }, + [types.CART_ADDING_ITEM] (state, { isAdding }) { + state.isAddingToCart = isAdding } } diff --git a/core/modules/cart/test/unit/components/AddToCart.spec.ts b/core/modules/cart/test/unit/components/AddToCart.spec.ts index 4acd4be6d..552a23ebe 100644 --- a/core/modules/cart/test/unit/components/AddToCart.spec.ts +++ b/core/modules/cart/test/unit/components/AddToCart.spec.ts @@ -1,13 +1,19 @@ import { mountMixinWithStore } from '@vue-storefront/unit-tests/utils'; - import Product from '@vue-storefront/core/modules/catalog/types/Product'; - import { AddToCart } from '../../../components/AddToCart' +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + log: jest.fn(() => () => {}), + debug: jest.fn(() => () => {}), + warn: jest.fn(() => () => {}), + error: jest.fn(() => () => {}) + } +})); +jest.mock('@vue-storefront/core/app', () => ({ createApp: jest.fn() })) +jest.mock('@vue-storefront/i18n', () => ({loadLanguageAsync: jest.fn()})) jest.mock('@vue-storefront/core/helpers', () => ({ once: jest.fn() })); -jest.mock('@vue-storefront/i18n', () => ({loadLanguageAsync: jest.fn()})) -jest.mock('@vue-storefront/core/app', () => ({ createApp: jest.fn() })) describe('AddToCart', () => { it('addToCart dispatches addItem action', () => { diff --git a/core/modules/cart/test/unit/components/Product.spec.ts b/core/modules/cart/test/unit/components/Product.spec.ts index fa088e7a0..178a04164 100644 --- a/core/modules/cart/test/unit/components/Product.spec.ts +++ b/core/modules/cart/test/unit/components/Product.spec.ts @@ -1,14 +1,18 @@ import {mountMixin, mountMixinWithStore} from '@vue-storefront/unit-tests/utils'; - import Product from '@vue-storefront/core/modules/catalog/types/Product'; -import { productThumbnailPath } from '@vue-storefront/core/helpers'; +import { productThumbnailPath, getThumbnailPath } from '@vue-storefront/core/helpers'; import config from 'config' import { MicrocartProduct } from '../../../components/Product'; import Mock = jest.Mock; jest.mock('@vue-storefront/core/helpers', () => ({ - productThumbnailPath: jest.fn() + productThumbnailPath: jest.fn(), + getThumbnailPath: jest.fn() })); +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('@vue-storefront/core/app', () => jest.fn()) +jest.mock('@vue-storefront/core/lib/multistore', () => jest.fn()) +jest.mock('@vue-storefront/core/lib/storage-manager', () => jest.fn()) describe('MicrocartProduct', () => { beforeEach(() => { @@ -31,17 +35,15 @@ describe('MicrocartProduct', () => { }; (productThumbnailPath as Mock).mockReturnValueOnce('thumbnail-path'); + (getThumbnailPath as Mock).mockReturnValueOnce('resized-thumbnail-path'); Object.defineProperty(navigator, 'onLine', { value: true, configurable: true }); const product = {} as any as Product; const wrapper = mountMixin(MicrocartProduct, { propsData: { product } }); - const getThumbnail = jest.fn(() => 'resized-thumbnail-path'); - - wrapper.setMethods({ getThumbnail }); expect((wrapper.vm as any).thumbnail).toEqual('resized-thumbnail-path'); - expect(getThumbnail).toBeCalledWith('thumbnail-path', 150, 150); + expect(getThumbnailPath).toBeCalledWith('thumbnail-path', 150, 150); }); it('thumbnail in offline mode returns thumbnail in greater size', () => { @@ -59,17 +61,15 @@ describe('MicrocartProduct', () => { }; (productThumbnailPath as Mock).mockReturnValueOnce('thumbnail-path'); + (getThumbnailPath as Mock).mockReturnValueOnce('resized-thumbnail-path'); Object.defineProperty(navigator, 'onLine', { value: false, configurable: true }); const product = {} as any as Product; const wrapper = mountMixin(MicrocartProduct, { propsData: { product } }); - const getThumbnail = jest.fn(() => 'resized-thumbnail-path'); - - wrapper.setMethods({ getThumbnail }); expect((wrapper.vm as any).thumbnail).toEqual('resized-thumbnail-path'); - expect(getThumbnail).toBeCalledWith('thumbnail-path', 300, 300); + expect(getThumbnailPath).toBeCalledWith('thumbnail-path', 300, 300); }); it('removeFromCart dispatches removeItem to remove product from cart', () => { diff --git a/core/modules/cart/test/unit/helpers/cartCacheHandler.spec.ts b/core/modules/cart/test/unit/helpers/cartCacheHandler.spec.ts index e24976e78..33aaee0d7 100644 --- a/core/modules/cart/test/unit/helpers/cartCacheHandler.spec.ts +++ b/core/modules/cart/test/unit/helpers/cartCacheHandler.spec.ts @@ -1,16 +1,26 @@ import Vue from 'vue' import Vuex from 'vuex' -import * as types from '../../../store/mutation-types' - -import { cartCacheHandlerFactory } from '../../../helpers/cartCacheHandler'; -Vue.use(Vuex); +import * as types from '../../../store/mutation-types' -Vue.prototype.$db = { - cartsCollection: { +const StorageManager = { + cart: { setItem: jest.fn() + }, + get (key) { + return this[key] } }; +const cartCacheHandlerFactory = require('../../../helpers/cartCacheHandler').cartCacheHandlerFactory + +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({StorageManager})) +jest.mock('@vue-storefront/core/helpers', () => ({ + isServer: () => false +})); +jest.mock('@vue-storefront/core/app', () => ({ createApp: jest.fn() })) +jest.mock('@vue-storefront/i18n', () => ({loadLanguageAsync: jest.fn()})) + +Vue.use(Vuex); describe('Cart afterRegistration', () => { beforeEach(() => { @@ -30,11 +40,11 @@ describe('Cart afterRegistration', () => { } }; - Vue.prototype.$db.cartsCollection.setItem.mockImplementationOnce(() => Promise.resolve('foo')); + StorageManager.get('cart').setItem.mockImplementationOnce(() => Promise.resolve('foo')); await cartCacheHandlerFactory(Vue)({ type: mutationType }, stateMock); - expect(Vue.prototype.$db.cartsCollection.setItem) + expect(StorageManager.get('cart').setItem) .toBeCalledWith('current-cart', stateMock.cart.cartItems); }); @@ -47,7 +57,7 @@ describe('Cart afterRegistration', () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementationOnce(() => {}); - Vue.prototype.$db.cartsCollection.setItem.mockImplementationOnce(() => Promise.reject('foo')); + StorageManager.get('cart').setItem.mockImplementationOnce(() => Promise.reject('foo')); await cartCacheHandlerFactory(Vue)({ type: types.CART_LOAD_CART }, stateMock); @@ -61,11 +71,11 @@ describe('Cart afterRegistration', () => { } }; - Vue.prototype.$db.cartsCollection.setItem.mockImplementationOnce(() => Promise.resolve('foo')); + StorageManager.get('cart').setItem.mockImplementationOnce(() => Promise.resolve('foo')); await cartCacheHandlerFactory(Vue)({ type: types.CART_LOAD_CART_SERVER_TOKEN }, stateMock); - expect(Vue.prototype.$db.cartsCollection.setItem) + expect(StorageManager.get('cart').setItem) .toBeCalledWith('current-cart-token', stateMock.cart.cartServerToken); }); @@ -78,7 +88,7 @@ describe('Cart afterRegistration', () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementationOnce(() => {}); - Vue.prototype.$db.cartsCollection.setItem.mockImplementationOnce(() => Promise.reject('foo')); + StorageManager.get('cart').setItem.mockImplementationOnce(() => Promise.reject('foo')); await cartCacheHandlerFactory(Vue)({ type: types.CART_LOAD_CART_SERVER_TOKEN }, stateMock); @@ -94,7 +104,7 @@ describe('Cart afterRegistration', () => { const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementationOnce(() => {}); - Vue.prototype.$db.cartsCollection.setItem.mockImplementationOnce(() => Promise.reject('foo')); + StorageManager.get('cart').setItem.mockImplementationOnce(() => Promise.reject('foo')); await cartCacheHandlerFactory(Vue)({ type: 'bar' }, stateMock); diff --git a/core/modules/cart/test/unit/helpers/createOrderData.spec.ts b/core/modules/cart/test/unit/helpers/createOrderData.spec.ts new file mode 100644 index 000000000..411162be9 --- /dev/null +++ b/core/modules/cart/test/unit/helpers/createOrderData.spec.ts @@ -0,0 +1,171 @@ +import createOrderData from '@vue-storefront/core/modules/cart/helpers/createOrderData'; + +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(), + localizedRoute: jest.fn() +})); + +const shippingDetails = { + country: 'UK', + firstName: 'John', + lastName: 'Doe', + city: 'London', + zipCode: 'EC123', + streetAddress: 'JohnDoe street', + region_id: 1, + apartmentNumber: '12', + state: 'xxxx', + phoneNumber: '123123123', + shippingMethod: 'method' +}; + +const paymentDetails = { + country: 'UK', + firstName: 'John', + lastName: 'Doe', + city: 'London', + zipCode: 'EC123', + streetAddress: 'JohnDoe street', + region_id: 1, + apartmentNumber: '12', + state: 'xxxx', + phoneNumber: '123123123', + company: '', + taxId: '', + paymentMethod: '', + paymentMethodAdditional: [] +}; + +describe('Cart createOrderData', () => { + it('returns data with default shipping and default payment', async () => { + const shippingMethods = [ + { + default: false, + offline: false, + method_code: 'CODE1', + carrier_code: 'CODE2' + }, + { + default: true, + offline: false, + method_code: 'CODE3', + carrier_code: 'CODE4' + } + ]; + + const paymentMethods = [ + { + default: false, + code: 'CODE3' + }, + { + default: true, + code: 'CODE4' + } + ]; + + const methodsData = createOrderData({ shippingDetails, shippingMethods, paymentMethods, paymentDetails, taxCountry: 'DE' }) + + expect(methodsData).toEqual({ + carrier_code: 'CODE4', + country: 'UK', + method_code: 'CODE3', + payment_method: 'CODE4', + shippingAddress: { + city: 'London', + firstname: 'John', + lastname: 'Doe', + postcode: 'EC123', + street: ['JohnDoe street'] + }, + billingAddress: { + city: 'London', + countryId: 'UK', + firstname: 'John', + lastname: 'Doe', + postcode: 'EC123', + street: ['JohnDoe street'] + } + }); + }); + + it('returns data with first online shipping and first payment', async () => { + const shippingMethods = [ + { + default: false, + offline: false, + method_code: 'CODE1-first', + carrier_code: 'CODE2-first' + }, + { + default: false, + offline: false, + method_code: 'CODE3', + carrier_code: 'CODE4' + } + ]; + + const paymentMethods = [ + { + default: false, + code: 'CODE3' + }, + { + default: false, + code: 'CODE4' + } + ]; + + const methodsData = createOrderData({ shippingDetails, shippingMethods, paymentMethods, paymentDetails, taxCountry: 'DE' }) + + expect(methodsData).toEqual({ + carrier_code: 'CODE2-first', + country: 'UK', + method_code: 'CODE1-first', + payment_method: 'CODE3', + shippingAddress: { + city: 'London', + firstname: 'John', + lastname: 'Doe', + postcode: 'EC123', + street: ['JohnDoe street'] + }, + billingAddress: { + city: 'London', + countryId: 'UK', + firstname: 'John', + lastname: 'Doe', + postcode: 'EC123', + street: ['JohnDoe street'] + } + }); + }); + + it('returns data without payment, carrier and method', async () => { + const shippingMethods = []; + const paymentMethods = []; + const methodsData = createOrderData({ shippingDetails, shippingMethods, paymentMethods, paymentDetails, taxCountry: 'DE' }); + + expect(methodsData).toEqual({ + carrier_code: null, + country: 'UK', + method_code: null, + payment_method: null, + shippingAddress: { + city: 'London', + firstname: 'John', + lastname: 'Doe', + postcode: 'EC123', + street: ['JohnDoe street'] + }, + billingAddress: { + city: 'London', + countryId: 'UK', + firstname: 'John', + lastname: 'Doe', + postcode: 'EC123', + street: ['JohnDoe street'] + } + }); + }); +}); diff --git a/core/modules/cart/test/unit/helpers/createShippingInfoData.spec.ts b/core/modules/cart/test/unit/helpers/createShippingInfoData.spec.ts new file mode 100644 index 000000000..ed2bfbfc8 --- /dev/null +++ b/core/modules/cart/test/unit/helpers/createShippingInfoData.spec.ts @@ -0,0 +1,79 @@ +import createShippingInfoData from './../../../helpers/createShippingInfoData'; + +describe('Cart createShippingInfoData', () => { + it('returns methods data', async () => { + const methodsData = { + country: 'UK', + carrier_code: 'XX', + method_code: 'YY' + }; + const shippingInfoData = createShippingInfoData(methodsData); + expect(shippingInfoData).toEqual({ + billingAddress: {}, + shippingAddress: { + countryId: 'UK' + }, + shippingCarrierCode: 'XX', + shippingMethodCode: 'YY' + }); + }); + + it('returns methods data with shipping address', async () => { + const methodsData = { + country: 'UK', + carrier_code: 'XX', + method_code: 'YY', + shippingAddress: { + city: 'London', + firstname: 'John', + lastname: 'Doe', + postcode: 'EC123', + street: ['JohnDoe street'] + } + }; + const shippingInfoData = createShippingInfoData(methodsData); + expect(shippingInfoData).toEqual({ + billingAddress: {}, + shippingAddress: { + city: 'London', + countryId: 'UK', + firstname: 'John', + lastname: 'Doe', + postcode: 'EC123', + street: ['JohnDoe street'] + }, + shippingCarrierCode: 'XX', + shippingMethodCode: 'YY' + }); + }); + + it('returns methods data with billing address', async () => { + const methodsData = { + country: 'UK', + carrier_code: 'XX', + method_code: 'YY', + billingAddress: { + city: 'London', + countryId: 'UK', + firstname: 'John', + lastname: 'Doe', + postcode: 'EC123', + street: ['JohnDoe street'] + } + }; + const shippingInfoData = createShippingInfoData(methodsData); + expect(shippingInfoData).toEqual({ + shippingAddress: { countryId: 'UK' }, + billingAddress: { + city: 'London', + countryId: 'UK', + firstname: 'John', + lastname: 'Doe', + postcode: 'EC123', + street: ['JohnDoe street'] + }, + shippingCarrierCode: 'XX', + shippingMethodCode: 'YY' + }); + }); +}); diff --git a/core/modules/cart/test/unit/helpers/prepareProductsToAdd.spec.ts b/core/modules/cart/test/unit/helpers/prepareProductsToAdd.spec.ts new file mode 100644 index 000000000..11506aec8 --- /dev/null +++ b/core/modules/cart/test/unit/helpers/prepareProductsToAdd.spec.ts @@ -0,0 +1,29 @@ +import CartItem from '@vue-storefront/core/modules/cart/types/CartItem' +import prepareProductsToAdd from './../../../helpers/prepareProductsToAdd'; + +jest.mock('@vue-storefront/core/modules/cart/helpers/productChecksum', () => () => 'some checksum') + +const createProduct = ({ type_id }): CartItem => ({ + type_id, + qty: 1, + product_links: [ + { + link_type: 'associated', + product: { + sku: 'SK-001' + } + } + ] +} as any as CartItem) + +describe('Cart prepareProductsToAdd', () => { + it('returns associated products', async () => { + const product = createProduct({ type_id: 'grouped' }) + expect(prepareProductsToAdd(product)).toEqual([{ sku: 'SK-001' }]) + }); + + it('returns products with checksum applied', async () => { + const product = createProduct({ type_id: 'bundle' }) + expect(prepareProductsToAdd(product)).toEqual([{ qty: 1, type_id: 'bundle', checksum: 'some checksum' }]) + }); +}); diff --git a/core/modules/cart/test/unit/helpers/prepareShippingInfoForUpdateTotals.spec.ts b/core/modules/cart/test/unit/helpers/prepareShippingInfoForUpdateTotals.spec.ts new file mode 100644 index 000000000..c1c9b50cc --- /dev/null +++ b/core/modules/cart/test/unit/helpers/prepareShippingInfoForUpdateTotals.spec.ts @@ -0,0 +1,32 @@ +import prepareShippingInfoForUpdateTotals from '@vue-storefront/core/modules/cart/helpers/prepareShippingInfoForUpdateTotals' +import Totals from '@vue-storefront/core/modules/cart/types/Totals' + +describe('Cart prepareShippingInfoForUpdateTotals', () => { + it('returns shipping info', () => { + const shippingInfoItems = [ + { item_id: 1, key1: 1, key2: 2 }, + { item_id: 2, key1: 3, key2: 4 }, + { item_id: 3, key1: 5, key2: 6 } + ] as any as Totals[] + + expect(prepareShippingInfoForUpdateTotals(shippingInfoItems)).toEqual({ + 1: { item_id: 1, key1: 1, key2: 2 }, + 2: { item_id: 2, key1: 3, key2: 4 }, + 3: { item_id: 3, key1: 5, key2: 6 } + }) + }); + + it('returns shipping info with options', () => { + const shippingInfoItems = [ + { item_id: 1, key1: 1, key2: 2, options: JSON.stringify({ opt1: 1, opt2: 2 }) }, + { item_id: 2, key1: 3, key2: 4, options: JSON.stringify({ opt1: 3, opt2: 4 }) }, + { item_id: 3, key1: 5, key2: 6, options: JSON.stringify({ opt1: 5, opt2: 6 }) } + ] as any as Totals[] + + expect(prepareShippingInfoForUpdateTotals(shippingInfoItems)).toEqual({ + 1: { item_id: 1, key1: 1, key2: 2, options: { opt1: 1, opt2: 2 } }, + 2: { item_id: 2, key1: 3, key2: 4, options: { opt1: 3, opt2: 4 } }, + 3: { item_id: 3, key1: 5, key2: 6, options: { opt1: 5, opt2: 6 } } + }) + }); +}); diff --git a/core/modules/cart/test/unit/helpers/productChecksum.spec.ts b/core/modules/cart/test/unit/helpers/productChecksum.spec.ts new file mode 100644 index 000000000..f3a5c9a9d --- /dev/null +++ b/core/modules/cart/test/unit/helpers/productChecksum.spec.ts @@ -0,0 +1,58 @@ +import CartItem from '@vue-storefront/core/modules/cart/types/CartItem' +import productChecksum from './../../../helpers/productChecksum'; + +const configurableProduct: CartItem = { + product_option: { + extension_attributes: { + configurable_item_options: [ + { + option_id: '93', + option_value: 53 + }, + { + option_id: '142', + option_value: 169 + } + ] + } + } +} as any as CartItem; + +const bundleProduct: CartItem = { + product_option: { + extension_attributes: { + bundle_options: [ + { + option_id: 1, + option_qty: 1, + option_selections: [2] + }, + { + option_id: 2, + option_qty: 1, + option_selections: [4] + }, + { + option_id: 3, + option_qty: 1, + option_selections: [5] + }, + { + option_id: 4, + option_qty: 1, + option_selections: [8] + } + ] + } + } +} as any as CartItem; + +describe('Cart productChecksum', () => { + it('returns checksum for bundle product', async () => { + expect(productChecksum(bundleProduct)).toBe('d8ba5d5baf59fe28647d6a08fdaeb683a7b39ccdebc77eecabc6457c'); + }); + + it('returns checksum for configurable product', async () => { + expect(productChecksum(configurableProduct)).toBe('0bbb27ec7a3cb5dfd1d3f6c4ee54c8b522c4063fe6ea0571794d446f'); + }); +}); diff --git a/core/modules/cart/test/unit/helpers/productEquals.spec.ts b/core/modules/cart/test/unit/helpers/productEquals.spec.ts new file mode 100644 index 000000000..d7612a073 --- /dev/null +++ b/core/modules/cart/test/unit/helpers/productEquals.spec.ts @@ -0,0 +1,88 @@ +import CartItem from '@vue-storefront/core/modules/cart/types/CartItem' +import productsEquals from './../../../helpers/productsEquals'; + +const createBundleOptions = (options) => { + if (!options) { + return [] + } + + return [ + { + option_id: 1, + option_qty: 1 + }, + { + option_id: 2, + option_qty: 1 + }, + { + option_id: 3, + option_qty: 1 + }, + { + option_id: 4, + option_qty: 1 + } + ].map((o, index) => ({ ...o, option_selections: [options[index]] })) +} + +const createBundleProduct = ({ id, sku, type_id, options }): CartItem => ({ + sku, + type_id, + server_item_id: id, + product_option: { + extension_attributes: { + bundle_options: createBundleOptions(options) + } + } +} as any as CartItem) + +const createConfigurableProduct = ({ id, sku }): CartItem => ({ + sku, + type_id: 'configurable', + server_item_id: id, + product_option: { + extension_attributes: { + configurable_item_options: [ + { + option_id: '93', + option_value: 53 + }, + { + option_id: '142', + option_value: 169 + } + ] + } + } +} as any as CartItem) + +describe('Cart productEquals', () => { + it('returns true because bundle products have the same options selected', async () => { + const product1 = createBundleProduct({ id: 1, sku: 'WG-001', type_id: 'bundle', options: [2, 4, 5, 8] }) + const product2 = createBundleProduct({ id: 2, sku: 'WG-001', type_id: 'bundle', options: [2, 4, 5, 8] }) + + 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 bundle products have the same server id', async () => { + const product1 = createBundleProduct({ id: 1, sku: 'WG-001', type_id: 'bundle', options: null }) + const product2 = createBundleProduct({ id: 1, sku: 'WG-001', type_id: 'none', options: [2, 4, 5, 8] }) + + expect(productsEquals(product1, product2)).toBeTruthy() + }); + + it('returns true because configurable products have the same eku', async () => { + const product1 = createConfigurableProduct({ id: 1, sku: 'WG-001' }) + const product2 = createConfigurableProduct({ id: 2, sku: 'WG-001' }) + + expect(productsEquals(product1, product2)).toBeTruthy() + }); +}); diff --git a/core/modules/cart/test/unit/helpers/validateProduct.spec.ts b/core/modules/cart/test/unit/helpers/validateProduct.spec.ts new file mode 100644 index 000000000..f9180c679 --- /dev/null +++ b/core/modules/cart/test/unit/helpers/validateProduct.spec.ts @@ -0,0 +1,28 @@ +import CartItem from '@vue-storefront/core/modules/cart/types/CartItem' +import validateProduct from '@vue-storefront/core/modules/cart/helpers/validateProduct' + +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('@vue-storefront/core/lib/multistore', () => jest.fn()) + +describe('Cart validateProduct', () => { + it('returns error about unknown price', () => { + const product = { + price_incl_tax: -1, + errors: {} + } as any as CartItem + + expect(validateProduct(product)).toEqual(['Product price is unknown, product cannot be added to the cart!']) + }); + + it('returns product errors', () => { + const product = { + price_incl_tax: 5, + errors: { + error1: 'error 1', + error2: 'error 2' + } + } as any as CartItem + + expect(validateProduct(product)).toEqual(['error 1', 'error 2']) + }); +}); diff --git a/core/modules/cart/test/unit/hooks/afterRegistration.spec.ts b/core/modules/cart/test/unit/hooks/afterRegistration.spec.ts deleted file mode 100644 index 83551fa11..000000000 --- a/core/modules/cart/test/unit/hooks/afterRegistration.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import Vue from 'vue' -import Vuex from 'vuex' - -import { cartCacheHandlerFactory } from '../../../helpers/cartCacheHandler'; -import { afterRegistration } from '../../../hooks/afterRegistration'; -import Mock = jest.Mock; - -Vue.use(Vuex); - -jest.mock('../../../helpers/cartCacheHandler', () => ({ cartCacheHandlerFactory: jest.fn() })); - -Vue.prototype.$db = { - cartsCollection: { - setItem: jest.fn() - } -}; - -describe('Cart afterRegistration', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('hook dispatches load action on browser side', () => { - const storeMock = { - modules: { - cart: { - actions: { - load: jest.fn() - }, - namespaced: true - } - } - }; - - afterRegistration({Vue, config: {}, store: new Vuex.Store(storeMock), isServer: false}); - - expect(storeMock.modules.cart.actions.load).toBeCalled() - }); - - it('hook subscribes to mutations with cartCacheHandler', () => { - const store = new Vuex.Store({}); - const storeSpy = jest.spyOn(store, 'subscribe'); - const cartCacheHandler = jest.fn(); - - (cartCacheHandlerFactory as Mock).mockReturnValueOnce(cartCacheHandler); - - afterRegistration({ Vue, config: {}, store, isServer: true }); - - expect(storeSpy).toBeCalledWith(cartCacheHandler); - }); -}); diff --git a/core/modules/cart/test/unit/hooks/beforeRegistration.spec.ts b/core/modules/cart/test/unit/hooks/beforeRegistration.spec.ts deleted file mode 100644 index c970c35f8..000000000 --- a/core/modules/cart/test/unit/hooks/beforeRegistration.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import Vue from 'vue' - -import UniversalStorage from '@vue-storefront/core/store/lib/storage'; -import { currentStoreView } from '@vue-storefront/core/lib/multistore'; -import { beforeRegistration } from '../../../hooks/beforeRegistration'; -import * as localForage from 'localforage'; -import Mock = jest.Mock; - -jest.mock('localforage', () => ({ createInstance: jest.fn(), someDriver: {} })); -jest.mock('@vue-storefront/core/store/lib/storage', () => jest.fn()); -jest.mock('@vue-storefront/core/lib/multistore', () => ({ currentStoreView: jest.fn() })); - -Vue.prototype.$db = {}; - -describe('Cart beforeRegistration', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('hook initializes cart cache without sufix in name', () => { - const config = { - storeViews: { - commonCache: true - }, - localForage: { - defaultDrivers: { - carts: 'someDriver' - } - } - }; - const storageMock = {foo: 'bar'}; - - (currentStoreView as Mock).mockReturnValueOnce({}); - (UniversalStorage as unknown as Mock).mockImplementationOnce(() => storageMock); - - beforeRegistration({ Vue, config, store: undefined, isServer: undefined }); - - expect(Vue.prototype.$db.cartsCollection).toEqual(storageMock); - }); - - it('hook initializes cart cache with storeCode sufix in name', () => { - const config = { - storeViews: { - commonCache: false - }, - localForage: { - defaultDrivers: { - carts: 'someDriver' - } - } - }; - const storageMock = {foo: 'bar'}; - - (currentStoreView as Mock).mockReturnValueOnce({ storeCode: 'baz' }); - (UniversalStorage as unknown as Mock).mockImplementationOnce(() => storageMock); - - beforeRegistration({ Vue, config, store: undefined, isServer: undefined }); - - expect(localForage.createInstance).toBeCalledWith({ - name: 'baz-shop', - storeName: 'carts', - driver: {} - }); - expect(Vue.prototype.$db.cartsCollection).toEqual(storageMock); - }); -}); diff --git a/core/modules/cart/test/unit/index.spec.ts b/core/modules/cart/test/unit/index.spec.ts index 655ed33b5..4b00340d6 100644 --- a/core/modules/cart/test/unit/index.spec.ts +++ b/core/modules/cart/test/unit/index.spec.ts @@ -1,12 +1,19 @@ -import { Cart } from '../../index' +import { CartModule } from '../../index' jest.mock('../../store', () => ({})); -jest.mock('@vue-storefront/core/lib/module', () => ({ createModule: jest.fn(() => ({ module: 'cart' })) })); -jest.mock('../../hooks/beforeRegistration', () => jest.fn()); -jest.mock('../../hooks/afterRegistration', () => jest.fn()); +jest.mock('@vue-storefront/core/lib/modules', () => ({ createModule: jest.fn(() => ({ module: 'cart' })) })); +jest.mock('../../helpers/cartCacheHandler', () => ({ cartCacheHandlerFactory: jest.fn() })) +jest.mock('@vue-storefront/core/helpers', () => ({ isServer: false })) +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ initCacheStorage: jest.fn() })); +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('@vue-storefront/core/app', () => jest.fn()) +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(), + localizedRoute: jest.fn() +})); describe('Cart Module', () => { it('can be initialized', () => { - expect(Cart).toBeTruthy() + expect(CartModule).toBeTruthy() }) }); diff --git a/core/modules/cart/test/unit/store/actions.spec.ts b/core/modules/cart/test/unit/store/actions.spec.ts deleted file mode 100644 index 572672cf0..000000000 --- a/core/modules/cart/test/unit/store/actions.spec.ts +++ /dev/null @@ -1,294 +0,0 @@ -import Vue from 'vue' - -import * as types from '../../../store/mutation-types'; -import cartActions from '../../../store/actions'; -import config from 'config'; -import rootStore from '@vue-storefront/core/store'; -import { sha3_224 } from 'js-sha3'; -import { TaskQueue } from '../../../../../lib/sync'; -import * as coreHelper from '@vue-storefront/core/helpers'; -import { currentStoreView } from '@vue-storefront/core/lib/multistore'; -import { onlineHelper } from '@vue-storefront/core/helpers'; - -jest.mock('@vue-storefront/core/store', () => ({ - dispatch: jest.fn(), - state: {} -})); -jest.mock('config', () => ({})); -jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); -jest.mock('js-sha3', () => ({ sha3_224: jest.fn() })); -jest.mock('@vue-storefront/core/lib/multistore', () => ({ - currentStoreView: jest.fn(), - localizedRoute: jest.fn() -})); -jest.mock('@vue-storefront/core/lib/logger', () => ({ - Logger: { - log: jest.fn(() => () => {}), - debug: jest.fn(() => () => {}), - warn: jest.fn(() => () => {}), - error: jest.fn(() => () => {}) - } -})); -jest.mock('@vue-storefront/core/lib/sync', () => ({ TaskQueue: { - execute: jest.fn() -}})); -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 - }, - onlineHelper: { - get isOnline () { - return true - } - } -})); - -Vue.prototype.$bus = { - $emit: jest.fn() -}; - -describe('Cart actions', () => { - const isServerSpy = jest.spyOn((coreHelper as any).default, 'isServer', 'get'); - const isOnlineSpy = jest.spyOn(onlineHelper, 'isOnline', 'get'); - - beforeEach(() => { - jest.clearAllMocks(); - (rootStore as any).state = {}; - Object.keys(config).forEach((key) => { delete config[key]; }); - }); - - it('disconnect clears cart token', () => { - const contextMock = { - commit: jest.fn() - }; - const wrapper = (actions: any) => actions.disconnect(contextMock); - - wrapper(cartActions); - - expect(contextMock.commit).toBeCalledWith(types.CART_LOAD_CART_SERVER_TOKEN, null); - }); - - it('clear deletes all cart products and token', async () => { - const contextMock = { - commit: jest.fn(), - getters: { isCartSyncEnabled: false } - }; - const wrapper = (actions: any) => actions.clear(contextMock); - - config.cart = { synchronize: false }; - - await wrapper(cartActions); - - expect(contextMock.commit).toBeCalledWith(types.CART_LOAD_CART, []); - }); - - it('clear dispatches creating a new cart on server with direct backend sync when its configured', async () => { - const contextMock = { - commit: jest.fn(), - dispatch: jest.fn(), - getters: { isCartSyncEnabled: true, isTotalsSyncRequired: true, isSyncRequired: true, isCartConnected: true } - }; - - config.cart = { synchronize: true }; - config.orders = { directBackendSync: true }; - - const wrapper = (actions: any) => actions.clear(contextMock); - - await wrapper(cartActions); - - expect(contextMock.dispatch).toBeCalledWith('connect', {guestCart: false}); - }); - - it('clear dispatches creating a new cart on server with queuing when direct backend sync is not configured', async () => { - const contextMock = { - commit: jest.fn(), - dispatch: jest.fn(), - getters: { isCartSyncEnabled: true, isTotalsSyncRequired: true, isSyncRequired: true, isCartConnected: true } - }; - - config.cart = { synchronize: true }; - config.orders = { directBackendSync: false }; - - const wrapper = (actions: any) => actions.clear(contextMock); - - await wrapper(cartActions); - - expect(contextMock.dispatch).toBeCalledWith('connect', {guestCart: true}); - }); - - describe('sync', () => { - it('doesn\'t update shipping methods if cart is empty', async () => { - const contextMock = { - rootGetters: { checkout: { isUserInCheckout: () => false } }, - getters: { isCartSyncEnabled: true, isTotalsSyncRequired: true, isSyncRequired: true, isCartConnected: true }, - dispatch: jest.fn(), - state: { - cartItems: [], - cartServerToken: 'some-token', - cartItemsHash: 'some-sha-hash' - } - }; - - config.cart = { synchronize: true }; - (rootStore as any).state = { - checkout: { - shippingDetails: { - country: 'pl' - } - } - }; - - const expectedState = { - cartItems: [], - cartItemsHash: 'new-hash', - cartServerPullAt: 1000003000 - }; - - isServerSpy.mockReturnValueOnce(false); - Date.now = jest.fn(() => expectedState.cartServerPullAt); - (sha3_224 as any).mockReturnValueOnce(expectedState.cartItemsHash); - (TaskQueue.execute as jest.Mock).mockImplementationOnce(() => Promise.resolve({})); - - const wrapper = (actions: any) => actions.serverPull(contextMock, {}); - - await wrapper(cartActions); - - expect(contextMock.dispatch).not.toBeCalledWith( - 'cart/syncShippingMethods', - { country_id: 'us' } - ); - }); - - it('does not do anything if synchronization is off', async () => { - const contextMock = { - rootGetters: { checkout: { isUserInCheckout: () => false } }, - getters: { isCartSyncEnabled: true, isTotalsSyncRequired: true, isSyncRequired: true, isCartConnected: true }, - dispatch: jest.fn() - }; - - config.cart = { synchronize: false }; - - const wrapper = (actions: any) => actions.serverPull(contextMock, {}); - - await wrapper(cartActions); - - expect(TaskQueue.execute).not.toBeCalled(); - }); - - it('does not do anything in SSR environment', async () => { - const contextMock = { - rootGetters: { checkout: { isUserInCheckout: () => false } }, - getters: { isCartSyncEnabled: true, isTotalsSyncRequired: true, isSyncRequired: true, isCartConnected: true }, - dispatch: jest.fn() - }; - - config.cart = { synchronize: true }; - - const wrapper = (actions: any) => actions.serverPull(contextMock, {}); - - await wrapper(cartActions); - - expect(TaskQueue.execute).not.toBeCalled(); - }); - }); - - describe('syncTotals', () => { - it('does not do anything if totals synchronization is off', () => { - const contextMock = { - rootGetters: { checkout: { isUserInCheckout: () => false } }, - dispatch: jest.fn(), - getters: { isCartSyncEnabled: false, isTotalsSyncEnabled: false, isTotalsSyncRequired: true, isSyncRequired: true, isCartConnected: true }, - state: { - cartServerToken: 'some-token' - } - }; - - config.cart = { synchronize_totals: false }; - - const wrapper = (actions: any) => actions.syncTotals(contextMock); - - wrapper(cartActions); - - expect(TaskQueue.execute).not.toBeCalled(); - }); - - it('does not do anything in SSR environment', () => { - const contextMock = { - getters: { - isTotalsSyncRequired: false - } - }; - - config.cart = { synchronize_totals: true }; - - const wrapper = (actions: any) => actions.syncTotals(contextMock); - - wrapper(cartActions); - - expect(TaskQueue.execute).not.toBeCalled(); - }); - }); - - describe('connect', () => { - it('requests to backend for creation of a new cart', async () => { - const contextMock = { - getters: { isCartSyncEnabled: true, isTotalsSyncRequired: true, isSyncRequired: true, isCartConnected: true }, - state: { - cartconnectdAt: 1000000000 - } - }; - - config.cart = { synchronize: true }; - - isServerSpy.mockReturnValueOnce(false); - Date.now = jest.fn(() => 1000003000); - (TaskQueue.execute as jest.Mock).mockImplementationOnce(() => Promise.resolve({})); - - const wrapper = (actions: any) => actions.connect(contextMock, {}); - - await wrapper(cartActions); - - expect(TaskQueue.execute).toBeCalled(); - }); - - it('requests to backend for creation of guest cart', async () => { - const contextMock = { - rootGetters: { checkout: { isUserInCheckout: () => false } }, - getters: { isCartSyncEnabled: true, isTotalsSyncRequired: true, isSyncRequired: true, isCartConnected: true }, - state: { - cartconnectdAt: 1000000000 - } - }; - - config.cart = { - synchronize: true, - create_endpoint: 'http://example.url/guest-cart/{{token}}' - }; - - isServerSpy.mockReturnValueOnce(false); - Date.now = jest.fn(() => 1000003000); - (TaskQueue.execute as jest.Mock).mockImplementationOnce(() => Promise.resolve({})); - - const wrapper = (actions: any) => actions.connect(contextMock, { guestCart: true }); - - await wrapper(cartActions); - expect(TaskQueue.execute).toBeCalledWith(expect.objectContaining({ url: 'http://example.url/guest-cart/' })) - }); - - it('does not do anything if totals synchronization is off', () => { - const contextMock = { - getters: { isCartSyncEnabled: false } - }; - - config.cart = { synchronize: false }; - - const wrapper = (actions: any) => actions.connect(contextMock, {}); - - wrapper(cartActions); - - expect(TaskQueue.execute).not.toBeCalled(); - }); - }); -}); diff --git a/core/modules/cart/test/unit/store/connectActions.spec.ts b/core/modules/cart/test/unit/store/connectActions.spec.ts new file mode 100644 index 000000000..107e52198 --- /dev/null +++ b/core/modules/cart/test/unit/store/connectActions.spec.ts @@ -0,0 +1,139 @@ +import * as types from '@vue-storefront/core/modules/cart/store/mutation-types' +import config from 'config'; +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' +import { CartService } from '@vue-storefront/core/data-resolver' +import cartActions from '@vue-storefront/core/modules/cart/store/actions'; +import { createContextMock } from '@vue-storefront/unit-tests/utils'; + +jest.mock('@vue-storefront/core/store', () => ({ + dispatch: jest.fn(), + state: {} +})); +jest.mock('js-sha3', () => ({ sha3_224: jest.fn() })); +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('config', () => ({})); +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(), + localizedRoute: jest.fn() +})); +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/data-resolver', () => ({ CartService: { + getCartToken: jest.fn() +}})); +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ + StorageManager: { + get: jest.fn() + } +})); +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 + }, + onlineHelper: { + get isOnline () { + return true + } + }, + processLocalizedURLAddress: (url) => url +})); + +describe('Cart connectActions', () => { + it('clears cart token and server hash', async () => { + const contextMock = createContextMock({ + getters: { + isCartSyncEnabled: true + } + }) + config.orders = { + directBackendSync: false + } + + await (cartActions as any).clear(contextMock) + + expect(contextMock.commit).toHaveBeenNthCalledWith(1, types.CART_LOAD_CART, []); + expect(contextMock.commit).toHaveBeenNthCalledWith(2, types.CART_LOAD_CART_SERVER_TOKEN, null); + expect(contextMock.commit).toHaveBeenNthCalledWith(3, types.CART_SET_ITEMS_HASH, null); + expect(contextMock.dispatch).toBeCalledWith('connect', { guestCart: true }); + }) + + it('disconnects cart', async () => { + const contextMock = createContextMock() + await (cartActions as any).disconnect(contextMock) + expect(contextMock.commit).toBeCalledWith(types.CART_LOAD_CART_SERVER_TOKEN, null); + }) + + it('authorizes server cart token', async () => { + (StorageManager.get as jest.Mock).mockImplementation(() => ({ + getItem: async () => 1 + })); + + const contextMock = createContextMock({ + getters: { + getCoupon: { + code: null + } + } + }) + + config.cart = { + bypassCartLoaderForAuthorizedUsers: false + } + + await (cartActions as any).authorize(contextMock) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'connect', { guestCart: false }); + }) + + it('creates cart token', async () => { + (CartService.getCartToken as jest.Mock).mockImplementation(async () => + ({ resultCode: 200, result: 'server-cart-token' }) + ); + + const contextMock = createContextMock({ + getters: { + isCartSyncEnabled: true + } + }) + + config.cart = { + serverMergeByDefault: false + } + + await (cartActions as any).connect(contextMock, {}) + expect(contextMock.commit).toBeCalledWith(types.CART_LOAD_CART_SERVER_TOKEN, 'server-cart-token') + expect(contextMock.dispatch).toBeCalledWith('sync', { forceClientState: false, dryRun: true }) + }) + + it('attempts bypassing guest cart', async () => { + (CartService.getCartToken as jest.Mock).mockImplementation(async () => + ({ resultCode: 401, result: null }) + ); + + const contextMock = createContextMock({ + getters: { + isCartSyncEnabled: true, + bypassCounter: 0 + } + }) + + config.cart = { + serverMergeByDefault: false + } + config.queues = { + maxCartBypassAttempts: 4 + } + + await (cartActions as any).connect(contextMock, {}) + expect(contextMock.commit).toBeCalledWith(types.CART_UPDATE_BYPASS_COUNTER, { counter: 1 }) + expect(contextMock.dispatch).toBeCalledWith('connect', { guestCart: true }) + }) +}) diff --git a/core/modules/cart/test/unit/store/couponActions.spec.ts b/core/modules/cart/test/unit/store/couponActions.spec.ts new file mode 100644 index 000000000..21adaca9d --- /dev/null +++ b/core/modules/cart/test/unit/store/couponActions.spec.ts @@ -0,0 +1,69 @@ +import cartActions from '@vue-storefront/core/modules/cart/store/actions'; +import { createContextMock } from '@vue-storefront/unit-tests/utils'; + +jest.mock('@vue-storefront/core/store', () => ({ + dispatch: jest.fn(), + state: {} +})); +jest.mock('js-sha3', () => ({ sha3_224: jest.fn() })); +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('config', () => ({})); +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(), + localizedRoute: jest.fn() +})); +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/data-resolver', () => ({ CartService: { + applyCoupon: async () => ({ result: true }), + removeCoupon: async () => ({ result: true }) +}})); +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ + StorageManager: { + get: jest.fn() + } +})); +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 + }, + onlineHelper: { + get isOnline () { + return true + } + }, + processLocalizedURLAddress: (url) => url +})); + +describe('Cart couponActions', () => { + it('applies coupon', async () => { + const contextMock = createContextMock({ + getters: { + canSyncTotals: true + } + }) + await (cartActions as any).applyCoupon(contextMock, 'coupon-code') + + expect(contextMock.dispatch).toBeCalledWith('syncTotals', { forceServerSync: true }) + }) + + it('removes coupon', async () => { + const contextMock = createContextMock({ + getters: { + canSyncTotals: true + } + }) + await (cartActions as any).removeCoupon(contextMock) + + expect(contextMock.dispatch).toBeCalledWith('syncTotals', { forceServerSync: true }) + }) +}) diff --git a/core/modules/cart/test/unit/store/getters.spec.ts b/core/modules/cart/test/unit/store/getters.spec.ts index 5b54f2ae2..166c6b69d 100644 --- a/core/modules/cart/test/unit/store/getters.spec.ts +++ b/core/modules/cart/test/unit/store/getters.spec.ts @@ -3,6 +3,9 @@ import { onlineHelper } from '@vue-storefront/core/helpers' import config from 'config' jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('@vue-storefront/core/lib/storage-manager', () => jest.fn()) +jest.mock('@vue-storefront/core/app', () => jest.fn()) +jest.mock('@vue-storefront/core/lib/multistore', () => jest.fn()) jest.mock('@vue-storefront/core/helpers', () => ({ onlineHelper: { get isOnline () { @@ -39,23 +42,27 @@ describe('Cart getters', () => { {'code': 'grand_total', 'title': 'Grand Total', 'value': 38.46, 'area': 'footer'} ] }; - const wrapper = (getters: any) => getters.getTotals(stateMock); + const wrapper = (getters: any) => getters.getTotals(stateMock, getters); expect(wrapper(cartGetters)).toEqual(stateMock.platformTotalSegments); }); - it(`totals returns totals without shipping and payment prices having neither platformTotalSegments + it(`totals returns totals without shipping and payment prices having neither platformTotalSegments nor additional prices`, () => { const stateMock = { cartItems: [ - {qty: 1, priceInclTax: 1}, - {qty: 2, priceInclTax: 2} + {qty: 1, price_incl_tax: 1}, + {qty: 2, price_incl_tax: 2} ] }; - const wrapper = (getters: any) => getters.getTotals(stateMock); + const wrapper = (getters: any) => getters.getTotals(stateMock, { + ...getters, + getFirstShippingMethod: getters.getFirstShippingMethod(stateMock), + getFirstPaymentMethod: getters.getFirstPaymentMethod(stateMock) + }); expect(wrapper(cartGetters)).toEqual([ - {'code': 'subtotalInclTax', 'title': 'Subtotal incl. tax', 'value': 5}, + {'code': 'subtotal_incl_tax', 'title': 'Subtotal incl. tax', 'value': 5}, {'code': 'grand_total', 'title': 'Grand total', 'value': 5} ]); }); @@ -68,59 +75,67 @@ describe('Cart getters', () => { {'code': 'shipping', 'title': 'Shipping & Handling (Flat Rate - Fixed)', 'value': 5} ], cartItems: [ - {qty: 1, priceInclTax: 1}, - {qty: 2, priceInclTax: 2} + {qty: 1, price_incl_tax: 1}, + {qty: 2, price_incl_tax: 2} ] }; - const wrapper = (getters: any) => getters.getTotals(stateMock); + const wrapper = (getters: any) => getters.getTotals(stateMock, { + ...getters, + getFirstShippingMethod: getters.getFirstShippingMethod(stateMock), + getFirstPaymentMethod: getters.getFirstPaymentMethod(stateMock) + }); expect(wrapper(cartGetters)).toEqual([ - {'code': 'subtotalInclTax', 'title': 'Subtotal incl. tax', 'value': 5}, + {'code': 'subtotal_incl_tax', 'title': 'Subtotal incl. tax', 'value': 5}, {'code': 'grand_total', 'title': 'Grand total', 'value': 5} ]); }); - it(`totals returns totals including shipping and payment prices having these prices in store + it(`totals returns totals including shipping and payment prices having these prices in store but no platformTotalSegments`, () => { const stateMock = { cartItems: [ - {qty: 1, priceInclTax: 1}, - {qty: 2, priceInclTax: 2} + {qty: 1, price_incl_tax: 1}, + {qty: 2, price_incl_tax: 2} ], payment: { title: 'payment', - costInclTax: 4 + cost_incl_tax: 4 }, shipping: { method_title: 'shipping', price_incl_tax: 8 } }; - const wrapper = (getters: any) => getters.getTotals(stateMock); + const wrapper = (getters: any) => getters.getTotals(stateMock, { + ...getters, + getFirstShippingMethod: getters.getFirstShippingMethod(stateMock), + getFirstPaymentMethod: getters.getFirstPaymentMethod(stateMock) + }); expect(wrapper(cartGetters)).toEqual([ - {'code': 'subtotalInclTax', 'title': 'Subtotal incl. tax', 'value': 5}, + {'code': 'subtotal_incl_tax', 'title': 'Subtotal incl. tax', 'value': 5}, {'code': 'grand_total', 'title': 'Grand total', 'value': 21}, {'code': 'payment', 'title': 'payment', 'value': 4}, {'code': 'shipping', 'title': 'shipping', 'value': 8} ]); }); - it(`totals returns totals including first shipping and first payment prices having multiple prices in store + it(`totals returns totals including first shipping and first payment prices having multiple prices in store but no platformTotalSegments`, () => { const stateMock = { cartItems: [ - {qty: 1, priceInclTax: 1}, - {qty: 2, priceInclTax: 2} + {qty: 1, price_incl_tax: 1}, + {qty: 2, price_incl_tax: 2} ], payment: [ { title: 'payment', - costInclTax: 4 + cost_incl_tax: 4 }, { title: 'another-payment', - costInclTax: 16 + cost_incl_tax: 16 } ], shipping: [ @@ -134,10 +149,14 @@ describe('Cart getters', () => { } ] }; - const wrapper = (getters: any) => getters.getTotals(stateMock); + const wrapper = (getters: any) => getters.getTotals(stateMock, { + ...getters, + getFirstShippingMethod: getters.getFirstShippingMethod(stateMock), + getFirstPaymentMethod: getters.getFirstPaymentMethod(stateMock) + }); expect(wrapper(cartGetters)).toEqual([ - {'code': 'subtotalInclTax', 'title': 'Subtotal incl. tax', 'value': 5}, + {'code': 'subtotal_incl_tax', 'title': 'Subtotal incl. tax', 'value': 5}, {'code': 'grand_total', 'title': 'Grand total', 'value': 21}, {'code': 'payment', 'title': 'payment', 'value': 4}, {'code': 'shipping', 'title': 'shipping', 'value': 8} diff --git a/core/modules/cart/test/unit/store/index.spec.ts b/core/modules/cart/test/unit/store/index.spec.ts index fbd42d883..4f5600979 100644 --- a/core/modules/cart/test/unit/store/index.spec.ts +++ b/core/modules/cart/test/unit/store/index.spec.ts @@ -1,5 +1,4 @@ -import { module } from '../../../store' - +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); jest.mock('../../../store/actions', () => ({})); jest.mock('../../../store/getters', () => ({})); jest.mock('../../../store/mutations', () => ({})); diff --git a/core/modules/cart/test/unit/store/itemActions.spec.ts b/core/modules/cart/test/unit/store/itemActions.spec.ts new file mode 100644 index 000000000..dda55f99d --- /dev/null +++ b/core/modules/cart/test/unit/store/itemActions.spec.ts @@ -0,0 +1,164 @@ +import * as types from '@vue-storefront/core/modules/cart/store/mutation-types' +import { configureProductAsync } from '@vue-storefront/core/modules/catalog/helpers' +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'; + +jest.mock('@vue-storefront/core/store', () => ({ + dispatch: jest.fn(), + state: {} +})); +jest.mock('js-sha3', () => ({ sha3_224: jest.fn() })); +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('config', () => ({})); +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(), + localizedRoute: jest.fn() +})); +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/data-resolver', () => ({ CartService: { + applyCoupon: async () => ({ result: true }), + removeCoupon: async () => ({ result: true }) +}})); +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ + StorageManager: { + get: jest.fn() + } +})); +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() +})); +jest.mock('@vue-storefront/core/modules/cart/helpers', () => ({ + prepareProductsToAdd: jest.fn(), + productsEquals: jest.fn(), + validateProduct: jest.fn(), + notifications: { + createNotifications: jest.fn() + }, + createDiffLog: () => ({ + pushNotifications: jest.fn() + }) +})); +jest.mock('@vue-storefront/core/helpers', () => ({ + get isServer () { + return true + }, + onlineHelper: { + get isOnline () { + return true + } + }, + processLocalizedURLAddress: (url) => url +})); + +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] + } + }) + + await (cartActions as any).configureItem(contextMock, { product: product1, configuration: {} }) + expect(contextMock.commit).toHaveBeenNthCalledWith(1, types.CART_DEL_ITEM, { product: product2 }) + expect(contextMock.commit).toHaveBeenNthCalledWith(2, types.CART_UPD_ITEM_PROPS, { product: { ...product1, ...product2 } }) + expect(contextMock.dispatch).toBeCalledWith('sync', { forceClientState: true }) + }) + + it('configures item', 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: [product1] + } + }) + + await (cartActions as any).configureItem(contextMock, { product: product1, configuration: {} }) + expect(contextMock.commit).not.toHaveBeenNthCalledWith(1, types.CART_DEL_ITEM, { product: product2 }) + expect(contextMock.commit).toHaveBeenNthCalledWith(1, types.CART_UPD_ITEM_PROPS, { product: { ...product1, ...product2 } }) + expect(contextMock.dispatch).toBeCalledWith('sync', { forceClientState: true }) + }) + + it('adds item to the cart', async () => { + const product1 = { sku: 1, name: 'product1', server_item_id: 1 } + const prepareProductsToAddMock = prepareProductsToAdd as jest.Mock + prepareProductsToAddMock.mockImplementation(() => [product1]) + const contextMock = createContextMock() + + await (cartActions as any).addItem(contextMock, { productToAdd: product1 }) + + expect(contextMock.commit).toHaveBeenNthCalledWith(1, types.CART_ADDING_ITEM, { isAdding: true }) + expect(contextMock.dispatch).toBeCalledWith('addItems', { productsToAdd: [product1], forceServerSilence: false }) + expect(contextMock.commit).toHaveBeenNthCalledWith(2, types.CART_ADDING_ITEM, { isAdding: false }) + }) + + it('checks product status', async () => { + (productsEquals as jest.Mock).mockImplementation(() => true) + + const product1 = { sku: 1, name: 'product1', server_item_id: 1, qty: 1 } + const contextMock = createContextMock({ + getters: { + getCartItems: [product1] + } + }) + + await (cartActions as any).checkProductStatus(contextMock, { product: product1 }) + expect(contextMock.dispatch).toBeCalledWith('stock/queueCheck', { product: product1, qty: 2 }, { root: true }) + }) + + it('adds items to the cart', async () => { + (validateProduct as jest.Mock).mockImplementation(() => []) + const product = { sku: 1, name: 'product1', server_item_id: 1, qty: 1 } + + const contextMock = createContextMock({ + getters: { + isCartSyncEnabled: true, + isCartConnected: true + } + }) + + contextMock.dispatch.mockImplementationOnce(() => Promise.resolve({ status: 'ok', onlineCheckTaskId: 1 })) + + await (cartActions as any).addItems(contextMock, { productsToAdd: [product] }) + expect(contextMock.commit).toBeCalledWith(types.CART_ADD_ITEM, { product: { ...product, onlineStockCheckid: 1 } }) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'checkProductStatus', { product }) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(2, 'sync', { forceClientState: true }) + }) + + it('removes item from the cart', async () => { + const product = { sku: 1, name: 'product1', server_item_id: 1, qty: 1 } + + const contextMock = createContextMock({ + getters: { + isCartSyncEnabled: true + } + }) + + await (cartActions as any).removeItem(contextMock, { product }) + expect(contextMock.commit).toBeCalledWith(types.CART_DEL_ITEM, { product, removeByParentSku: false }) + expect(contextMock.dispatch).toBeCalledWith('sync', { forceClientState: true }) + }) +}) diff --git a/core/modules/cart/test/unit/store/mergeActions.spec.ts b/core/modules/cart/test/unit/store/mergeActions.spec.ts new file mode 100644 index 000000000..6342f7c4a --- /dev/null +++ b/core/modules/cart/test/unit/store/mergeActions.spec.ts @@ -0,0 +1,358 @@ +import * as types from '@vue-storefront/core/modules/cart/store/mutation-types'; +import config from 'config'; +import { CartService } from '@vue-storefront/core/data-resolver'; +import { + productsEquals, + createCartItemForUpdate, + createDiffLog +} 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'; + +jest.mock('@vue-storefront/core/store', () => ({ + dispatch: jest.fn(), + state: {} +})); +jest.mock('js-sha3', () => ({ sha3_224: jest.fn() })); +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('config', () => ({})); +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(), + localizedRoute: jest.fn() +})); +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/data-resolver', () => ({ + CartService: { + applyCoupon: async () => ({ result: true }), + removeCoupon: async () => ({ result: true }), + updateItem: jest.fn() + } +})); +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ + StorageManager: { + get: jest.fn() + } +})); +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() +})); +jest.mock('@vue-storefront/core/modules/cart/helpers', () => ({ + prepareProductsToAdd: jest.fn(), + productsEquals: jest.fn(), + validateProduct: jest.fn(), + notifications: { + createNotifications: jest.fn() + }, + createCartItemForUpdate: jest.fn(), + createDiffLog: jest.fn(() => ({ + pushNotifications: jest.fn(), + pushServerResponse: jest.fn(), + pushServerParty: jest.fn(), + pushClientParty: jest.fn(), + merge: jest.fn(), + isEmpty: jest.fn() + })) +})); +jest.mock('@vue-storefront/core/helpers', () => ({ + get isServer () { + return true; + }, + onlineHelper: { + get isOnline () { + return true; + } + }, + processLocalizedURLAddress: url => url +})); + +describe('Cart mergeActions', () => { + it('updates client item', async () => { + const clientItem = { sku: 1, name: 'product1', qty: 2, server_item_id: 1 }; + const serverItem = { + sku: 1, + name: 'product1', + server_item_id: 1, + server_cart_id: 12, + product_option: 'a', + product_type: 'b', + qty: 2, + item_id: 1 + }; + + const contextMock = createContextMock(); + + await (cartActions as any).updateClientItem(contextMock, { clientItem, serverItem }); + + expect(contextMock.dispatch).toBeCalledWith('updateItem', { + product: { + prev_qty: 2, + product_option: 'a', + server_cart_id: undefined, + server_item_id: 1, + sku: 1, + type_id: 'b' + } + }); + }); + + it('updates server item - removes when updating was not successful', async () => { + const clientItem = { sku: 1, name: 'product1', qty: 2, server_item_id: 1, item_id: 1 }; + const serverItem = { + sku: 1, + name: 'product1', + server_item_id: 1, + server_cart_id: 12, + product_option: 'a', + product_type: 'b', + qty: 2, + item_id: 1 + }; + + (createCartItemForUpdate as jest.Mock).mockImplementation(() => clientItem); + (CartService.updateItem as jest.Mock).mockImplementation(() => Promise.resolve({ resultCode: 500 })); + + const contextMock = createContextMock({ + getters: { + getCartToken: 'cart-token' + } + }); + + await (cartActions as any).updateServerItem(contextMock, { clientItem, serverItem: null }); + expect(contextMock.commit).toBeCalledWith(types.CART_DEL_ITEM, { product: clientItem, removeByParentSku: false }) + }) + + it('updates server item - restoring quantity', async () => { + const clientItem = { sku: 1, name: 'product1', qty: 2, server_item_id: 1, item_id: 1 }; + const serverItem = { + sku: 1, + name: 'product1', + server_item_id: 1, + server_cart_id: 12, + product_option: 'a', + product_type: 'b', + qty: 2, + item_id: 1 + }; + + (createCartItemForUpdate as jest.Mock).mockImplementation(() => clientItem); + (CartService.updateItem as jest.Mock).mockImplementation(() => Promise.resolve({ resultCode: 500 })); + + const contextMock = createContextMock({ + getters: { + getCartToken: 'cart-token' + } + }); + + await (cartActions as any).updateServerItem(contextMock, { clientItem, serverItem }); + expect(contextMock.commit).not.toBeCalledWith(types.CART_DEL_ITEM, { product: clientItem, removeByParentSku: false }) + expect(contextMock.dispatch).toBeCalledWith('restoreQuantity', { cartItem: clientItem, clientItem }) + }) + + it('updates server item - deletes non confirmed item', async () => { + const clientItem = { sku: 1, name: 'product1', qty: 2, server_item_id: 1 }; + const serverItem = { + sku: 1, + name: 'product1', + server_item_id: 1, + server_cart_id: 12, + product_option: 'a', + product_type: 'b', + qty: 2, + item_id: 1 + }; + + (createCartItemForUpdate as jest.Mock).mockImplementation(() => clientItem); + (CartService.updateItem as jest.Mock).mockImplementation(() => Promise.resolve({ resultCode: 500 })); + + const contextMock = createContextMock({ + getters: { + getCartToken: 'cart-token' + } + }); + + await (cartActions as any).updateServerItem(contextMock, { clientItem, serverItem }); + expect(contextMock.commit).not.toBeCalledWith(types.CART_DEL_ITEM, { product: clientItem, removeByParentSku: false }) + expect(contextMock.dispatch).not.toBeCalledWith('restoreQuantity', { cartItem: clientItem, clientItem }) + expect(contextMock.commit).toBeCalledWith(types.CART_DEL_NON_CONFIRMED_ITEM, { product: clientItem }) + }) + + it('updates server item - apply changes for client item', async () => { + const clientItem = { sku: 1, name: 'product1', qty: 2, server_item_id: 1 }; + const serverItem = { + sku: 1, + name: 'product1', + server_item_id: 1, + server_cart_id: 12, + product_option: 'a', + product_type: 'b', + qty: 2, + item_id: 1 + }; + + (createCartItemForUpdate as jest.Mock).mockImplementation(() => clientItem); + (CartService.updateItem as jest.Mock).mockImplementation(() => Promise.resolve({ resultCode: 200, result: serverItem })); + + const contextMock = createContextMock({ + getters: { + getCartToken: 'cart-token' + }, + rootGetters: { + 'checkout/isUserInCheckout': true + } + }); + + await (cartActions as any).updateServerItem(contextMock, { clientItem, serverItem }); + expect(contextMock.commit).not.toBeCalledWith(types.CART_DEL_ITEM, { product: clientItem, removeByParentSku: false }) + expect(contextMock.dispatch).not.toBeCalledWith('restoreQuantity', { cartItem: clientItem, clientItem }) + expect(contextMock.commit).not.toBeCalledWith(types.CART_DEL_NON_CONFIRMED_ITEM, { product: clientItem }) + expect(contextMock.dispatch).toBeCalledWith('updateClientItem', { clientItem, serverItem: serverItem }) + }) + + it('synchronizes item with server when there is no given server item', async () => { + const clientItem = { sku: 1, name: 'product1', qty: 2, server_item_id: 1 }; + const serverItem = { + sku: 1, + name: 'product1', + server_item_id: 1, + server_cart_id: 12, + product_option: 'a', + product_type: 'b', + qty: 2, + item_id: 1 + }; + + config.cart = { + serverSyncCanRemoveLocalItems: false + } + + const contextMock = createContextMock() + + await (cartActions as any).synchronizeServerItem(contextMock, { clientItem, serverItem: null }); + expect(contextMock.dispatch).toBeCalledWith('updateServerItem', { clientItem, serverItem: null, updateIds: false }) + }) + + it('synchronizes item with server when there quantities are different', async () => { + const clientItem = { sku: 1, name: 'product1', qty: 2, server_item_id: 1 }; + const serverItem = { + sku: 1, + name: 'product1', + server_item_id: 1, + server_cart_id: 12, + product_option: 'a', + product_type: 'b', + qty: 1, + item_id: 1 + }; + + config.cart = { + serverSyncCanRemoveLocalItems: false + } + + const contextMock = createContextMock() + + await (cartActions as any).synchronizeServerItem(contextMock, { clientItem, serverItem }); + expect(contextMock.dispatch).not.toBeCalledWith('updateServerItem', { clientItem, serverItem: null, updateIds: false }) + expect(contextMock.dispatch).toBeCalledWith('updateServerItem', { clientItem, serverItem, updateIds: true }) + }) + + it('merges client item', async () => { + const clientItem = { sku: 1, name: 'product1', qty: 2, server_item_id: 1 }; + const serverItem = { + sku: 1, + name: 'product1', + server_item_id: 1, + server_cart_id: 12, + product_option: 'a', + product_type: 'b', + qty: 1, + item_id: 1 + }; + + const contextMock = createContextMock(); + (productsEquals as jest.Mock).mockImplementation(() => true); + (contextMock.dispatch as jest.Mock).mockImplementationOnce(() => Promise.resolve({ isEmpty: () => true })); + + await (cartActions as any).mergeClientItem(contextMock, { clientItem, serverItems: [serverItem], forceClientState: false, dryRun: false }); + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'synchronizeServerItem', { serverItem, clientItem, forceClientState: false, dryRun: false }) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(2, 'updateItem', { product: { product_option: 'a', server_cart_id: undefined, server_item_id: 1, sku: 1, type_id: 'b' } }) + }) + + it('merges server item', async () => { + const clientItem = { sku: 1, name: 'product1', qty: 2, server_item_id: 1 }; + const serverItem = { + sku: 1, + name: 'product1', + server_item_id: 1, + server_cart_id: 12, + product_option: 'a', + product_type: 'b', + qty: 1, + item_id: 1 + }; + + const contextMock = createContextMock(); + + (productsEquals as jest.Mock).mockImplementation(() => false); + (contextMock.dispatch as jest.Mock).mockImplementationOnce(() => Promise.resolve(serverItem)); + + await (cartActions as any).mergeServerItem(contextMock, { clientItems: [clientItem], serverItem, forceClientState: false, dryRun: false }); + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'getProductVariant', { serverItem }) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(2, 'addItem', { productToAdd: serverItem, forceServerSilence: true }) + }) + + it('updates totals after merge', async () => { + const clientItem = { sku: 1, name: 'product1', qty: 2, server_item_id: 1 }; + + const contextMock = createContextMock({ + getters: { + isTotalsSyncRequired: true, + getCurrentCartHash: 'cart-hash' + } + }); + await (cartActions as any).updateTotalsAfterMerge(contextMock, { clientItems: [clientItem], dryRun: false }); + expect(contextMock.dispatch).toBeCalledWith('syncTotals') + expect(contextMock.commit).toBeCalledWith(types.CART_SET_ITEMS_HASH, 'cart-hash') + }) + + it('merges client and server cart', async () => { + const clientItem = { sku: 1, name: 'product1', qty: 2, server_item_id: 1 }; + const serverItem = { + sku: 1, + name: 'product1', + server_item_id: 1, + server_cart_id: 12, + product_option: 'a', + product_type: 'b', + qty: 1, + item_id: 1 + }; + const contextMock = createContextMock({ + getters: { + isCartHashChanged: false + } + }); + + const diffLog = { + pushServerParty: () => diffLog, + pushClientParty: () => diffLog, + merge: () => diffLog + }; + + (createDiffLog as jest.Mock).mockImplementation(() => diffLog) + + await (cartActions as any).merge(contextMock, { clientItems: [clientItem], serverItems: [serverItem] }); + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'mergeClientItems', { clientItems: [clientItem], serverItems: [serverItem], dryRun: false, forceClientState: false }) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(2, 'mergeServerItems', { clientItems: [clientItem], serverItems: [serverItem], dryRun: false, forceClientState: false }) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(3, 'updateTotalsAfterMerge', { clientItems: [clientItem], dryRun: false }) + }) +}); diff --git a/core/modules/cart/test/unit/store/methodsActions.spec.ts b/core/modules/cart/test/unit/store/methodsActions.spec.ts new file mode 100644 index 000000000..907410a94 --- /dev/null +++ b/core/modules/cart/test/unit/store/methodsActions.spec.ts @@ -0,0 +1,162 @@ +import * as types from '@vue-storefront/core/modules/cart/store/mutation-types'; +import { CartService } from '@vue-storefront/core/data-resolver'; +import { preparePaymentMethodsToSync, createOrderData } 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'; + +jest.mock('@vue-storefront/core/store', () => ({ + dispatch: jest.fn(), + state: {} +})); +jest.mock('js-sha3', () => ({ sha3_224: jest.fn() })); +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('config', () => ({})); +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(), + localizedRoute: jest.fn() +})); +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/data-resolver', () => ({ + CartService: { + applyCoupon: async () => ({ result: true }), + removeCoupon: async () => ({ result: true }), + getPaymentMethods: jest.fn(), + updateItem: jest.fn(), + getShippingMethods: jest.fn() + } +})); +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ + StorageManager: { + get: jest.fn() + } +})); +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() +})); +jest.mock('@vue-storefront/core/modules/cart/helpers', () => ({ + prepareProductsToAdd: jest.fn(), + productsEquals: jest.fn(), + validateProduct: jest.fn(), + notifications: { + createNotifications: jest.fn() + }, + createCartItemForUpdate: jest.fn(), + createDiffLog: jest.fn(() => ({ + pushNotifications: jest.fn(), + pushServerResponse: jest.fn(), + pushServerParty: jest.fn(), + pushClientParty: jest.fn(), + merge: jest.fn(), + isEmpty: jest.fn() + })), + preparePaymentMethodsToSync: jest.fn(), + createOrderData: jest.fn() +})); +jest.mock('@vue-storefront/core/helpers', () => ({ + get isServer () { + return true; + }, + onlineHelper: { + get isOnline () { + return true; + } + }, + processLocalizedURLAddress: url => url +})); + +describe('Cart methodsActions', () => { + it('fetches payment and shipping methods', async () => { + const contextMock = createContextMock({ + getters: { + isTotalsSyncRequired: true + } + }) + + await (cartActions as any).pullMethods(contextMock, { forceServerSync: false }); + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'syncShippingMethods', { forceServerSync: false }) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(2, 'syncPaymentMethods', { forceServerSync: false }) + }); + + it('sets default shipping methods', async () => { + const contextMock = createContextMock({ + rootGetters: { + 'checkout/getDefaultShippingMethod': { shipping: 1 } + }, + getters: { + getShippingMethodCode: false, + getPaymentMethodCode: true + } + }) + + await (cartActions as any).setDefaultCheckoutMethods(contextMock); + expect(contextMock.commit).toBeCalledWith(types.CART_UPD_SHIPPING, { shipping: 1 }) + }) + + it('sets default payment methods', async () => { + const contextMock = createContextMock({ + rootGetters: { + 'checkout/getDefaultPaymentMethod': { payment: 1 } + }, + getters: { + getShippingMethodCode: true, + getPaymentMethodCode: false + } + }) + + await (cartActions as any).setDefaultCheckoutMethods(contextMock); + expect(contextMock.commit).toBeCalledWith(types.CART_UPD_PAYMENT, { payment: 1 }) + }) + + it('synchronizes payment methods', async () => { + const contextMock = createContextMock({ + rootGetters: { + 'checkout/getNotServerPaymentMethods': [], + 'checkout/getPaymentDetails': { country: 'US' } + }, + getters: { + canUpdateMethods: true, + isTotalsSyncRequired: true + } + }); + + (CartService.getPaymentMethods as jest.Mock).mockImplementation(() => Promise.resolve({ result: {} })); + (createOrderData as jest.Mock).mockImplementation(() => ({ shippingMethodsData: {} })); + (preparePaymentMethodsToSync as jest.Mock).mockImplementation(() => ({ uniqueBackendMethods: [], paymentMethods: [] })); + + await (cartActions as any).syncPaymentMethods(contextMock, {}); + expect(contextMock.dispatch).toBeCalledWith('checkout/replacePaymentMethods', [], { root: true }) + }) + + it('synchronizes shipping methods', async () => { + const contextMock = createContextMock({ + rootGetters: { + 'checkout/getShippingDetails': { country: 'US' } + }, + getters: { + canUpdateMethods: true, + isTotalsSyncRequired: true + } + }); + + (CartService.getShippingMethods as jest.Mock).mockImplementation(() => Promise.resolve({ result: [] })); + + await (cartActions as any).syncShippingMethods(contextMock, {}); + expect(contextMock.dispatch).toBeCalledWith('updateShippingMethods', { shippingMethods: [] }) + }) + + it('updates shipping methods', async () => { + const contextMock = createContextMock() + await (cartActions as any).updateShippingMethods(contextMock, { shippingMethods: [{ method: 1 }] }); + expect(contextMock.dispatch).toBeCalledWith('checkout/replaceShippingMethods', [{ is_server_method: true, method: 1 }], { root: true }) + }) +}); diff --git a/core/modules/cart/test/unit/store/mutations.spec.ts b/core/modules/cart/test/unit/store/mutations.spec.ts index 0cac88f2e..9eb2fc094 100644 --- a/core/modules/cart/test/unit/store/mutations.spec.ts +++ b/core/modules/cart/test/unit/store/mutations.spec.ts @@ -1,14 +1,11 @@ -import Vue from 'vue' import * as types from '../../../store/mutation-types' import cartMutations from '../../../store/mutations' - +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' jest.mock('@vue-storefront/core/helpers', () => ({ once: (str) => jest.fn() })) -Vue.prototype.$bus = { - $emit: jest.fn() -} +EventBus.$emit = jest.fn() jest.mock('@vue-storefront/core/store', () => ({ state: { @@ -42,7 +39,7 @@ describe('Cart mutations', () => { wrapper(cartMutations) - expect(Vue.prototype.$bus.$emit).toBeCalledWith('cart-before-add', { product }) + expect(EventBus.$emit).toBeCalledWith('cart-before-add', { product }) expect(stateMock).toEqual(expectedState) }) @@ -65,7 +62,7 @@ describe('Cart mutations', () => { wrapper(cartMutations) - expect(Vue.prototype.$bus.$emit).toBeCalledWith('cart-before-add', { product: { ...product, qty: 1 } }) + expect(EventBus.$emit).toBeCalledWith('cart-before-add', { product: { ...product, qty: 1 } }) expect(stateMock).toEqual(expectedState) }) @@ -148,13 +145,13 @@ describe('Cart mutations', () => { wrapper(cartMutations) - expect(Vue.prototype.$bus.$emit).toBeCalledWith('cart-before-delete', { + expect(EventBus.$emit).toBeCalledWith('cart-before-delete', { items: [{ qty: 10, sku: 'foo' }] }) - expect(Vue.prototype.$bus.$emit).toBeCalledWith('cart-after-delete', {items: expectedState.cartItems}) + expect(EventBus.$emit).toBeCalledWith('cart-after-delete', {items: expectedState.cartItems}) expect(stateMock).toEqual(expectedState) }) @@ -179,13 +176,13 @@ describe('Cart mutations', () => { wrapper(cartMutations) - expect(Vue.prototype.$bus.$emit).toBeCalledWith('cart-before-delete', { + expect(EventBus.$emit).toBeCalledWith('cart-before-delete', { items: [{ qty: 10, sku: 'foo' }] }) - expect(Vue.prototype.$bus.$emit).toBeCalledWith('cart-after-delete', {items: expectedState.cartItems}) + expect(EventBus.$emit).toBeCalledWith('cart-after-delete', {items: expectedState.cartItems}) expect(stateMock).toEqual(expectedState) }) }) @@ -211,13 +208,13 @@ describe('Cart mutations', () => { ) wrapper(cartMutations) - expect(Vue.prototype.$bus.$emit).toBeCalledWith('cart-before-delete', { + expect(EventBus.$emit).toBeCalledWith('cart-before-delete', { items: [{ qty: 10, sku: 'foo' }] }) - expect(Vue.prototype.$bus.$emit).toBeCalledWith('cart-after-delete', {items: expectedState.cartItems}) + expect(EventBus.$emit).toBeCalledWith('cart-after-delete', {items: expectedState.cartItems}) expect(stateMock).toEqual(expectedState) }) @@ -242,13 +239,13 @@ describe('Cart mutations', () => { wrapper(cartMutations) - expect(Vue.prototype.$bus.$emit).toBeCalledWith('cart-before-delete', { + expect(EventBus.$emit).toBeCalledWith('cart-before-delete', { items: [{ qty: 10, sku: 'foo' }] }) - expect(Vue.prototype.$bus.$emit).toBeCalledWith('cart-after-delete', {items: expectedState.cartItems}) + expect(EventBus.$emit).toBeCalledWith('cart-after-delete', {items: expectedState.cartItems}) expect(stateMock).toEqual(expectedState) }) @@ -281,8 +278,8 @@ describe('Cart mutations', () => { wrapper(cartMutations) - expect(Vue.prototype.$bus.$emit).toBeCalledWith('cart-before-delete', {items: stateMock.cartItems}) - expect(Vue.prototype.$bus.$emit).toBeCalledWith('cart-after-delete', {items: expectedState.cartItems}) + expect(EventBus.$emit).toBeCalledWith('cart-before-delete', {items: stateMock.cartItems}) + expect(EventBus.$emit).toBeCalledWith('cart-after-delete', {items: expectedState.cartItems}) expect(stateMock).toEqual(expectedState) }) }) @@ -316,8 +313,8 @@ describe('Cart mutations', () => { // unfortunately before and after events return a reference to the same object, therefore // after performing this mutation after event return same object with same, updated value as before event - expect(Vue.prototype.$bus.$emit).toBeCalledWith('cart-before-update', { product: expectedState.cartItems[0] }) - expect(Vue.prototype.$bus.$emit).toBeCalledWith('cart-after-update', { product: expectedState.cartItems[0] }) + expect(EventBus.$emit).toBeCalledWith('cart-before-update', { product: expectedState.cartItems[0] }) + expect(EventBus.$emit).toBeCalledWith('cart-after-update', { product: expectedState.cartItems[0] }) expect(stateMock).toEqual(expectedState) }) @@ -341,7 +338,7 @@ describe('Cart mutations', () => { wrapper(cartMutations) - expect(Vue.prototype.$bus.$emit).not.toBeCalled() + expect(EventBus.$emit).not.toBeCalled() expect(stateMock).toEqual(expectedState) }) }) @@ -374,14 +371,14 @@ describe('Cart mutations', () => { ) let firstEmitCall = [] - Vue.prototype.$bus.$emit.mockImplementationOnce((eventName, args) => { + EventBus.$emit.mockImplementationOnce((eventName, args) => { firstEmitCall.push(eventName) firstEmitCall.push(args) }) wrapper(cartMutations) expect(firstEmitCall).toEqual(['cart-before-itemchanged', { item: expectedState.cartItems[0] }]) - expect(Vue.prototype.$bus.$emit).toBeCalledWith('cart-after-itemchanged', { item: expectedState.cartItems[0] }) + expect(EventBus.$emit).toBeCalledWith('cart-after-itemchanged', { item: expectedState.cartItems[0] }) expect(stateMock).toEqual(expectedState) }) @@ -414,14 +411,14 @@ describe('Cart mutations', () => { ) let firstEmitCall = [] - Vue.prototype.$bus.$emit.mockImplementationOnce((eventName, args) => { + EventBus.$emit.mockImplementationOnce((eventName, args) => { firstEmitCall.push(eventName) firstEmitCall.push(args) }) wrapper(cartMutations) expect(firstEmitCall).toEqual(['cart-before-itemchanged', { item: expectedState.cartItems[0] }]) - expect(Vue.prototype.$bus.$emit).toBeCalledWith('cart-after-itemchanged', { item: expectedState.cartItems[0] }) + expect(EventBus.$emit).toBeCalledWith('cart-after-itemchanged', { item: expectedState.cartItems[0] }) expect(stateMock).toEqual(expectedState) }) @@ -445,7 +442,7 @@ describe('Cart mutations', () => { wrapper(cartMutations) - expect(Vue.prototype.$bus.$emit).not.toBeCalled() + expect(EventBus.$emit).not.toBeCalled() expect(stateMock).toEqual(expectedState) }) }) @@ -484,9 +481,9 @@ describe('Cart mutations', () => { ) wrapper(cartMutations) - expect(Vue.prototype.$bus.$emit).toBeCalledWith('sync/PROCESS_QUEUE', expect.anything()) - expect(Vue.prototype.$bus.$emit).toBeCalledWith('application-after-loaded') - expect(Vue.prototype.$bus.$emit).toBeCalledWith('cart-after-loaded') + expect(EventBus.$emit).toBeCalledWith('sync/PROCESS_QUEUE', expect.anything()) + expect(EventBus.$emit).toBeCalledWith('application-after-loaded') + expect(EventBus.$emit).toBeCalledWith('cart-after-loaded') expect(stateMock).toEqual(expectedState) }) @@ -502,9 +499,9 @@ describe('Cart mutations', () => { wrapper(cartMutations) - expect(Vue.prototype.$bus.$emit).toBeCalledWith('sync/PROCESS_QUEUE', expect.anything()) - expect(Vue.prototype.$bus.$emit).toBeCalledWith('application-after-loaded') - expect(Vue.prototype.$bus.$emit).toBeCalledWith('cart-after-loaded') + expect(EventBus.$emit).toBeCalledWith('sync/PROCESS_QUEUE', expect.anything()) + expect(EventBus.$emit).toBeCalledWith('application-after-loaded') + expect(EventBus.$emit).toBeCalledWith('cart-after-loaded') expect(stateMock).toEqual(expectedState) }) @@ -546,7 +543,7 @@ describe('Cart mutations', () => { wrapper(cartMutations) - expect(Vue.prototype.$bus.$emit).toBeCalledWith('cart-after-updatetotals', { + expect(EventBus.$emit).toBeCalledWith('cart-after-updatetotals', { platformTotals: expectedState.platformTotals, platformTotalSegments: expectedState.platformTotalSegments }) diff --git a/core/modules/cart/test/unit/store/productActions.spec.ts b/core/modules/cart/test/unit/store/productActions.spec.ts new file mode 100644 index 000000000..21491f416 --- /dev/null +++ b/core/modules/cart/test/unit/store/productActions.spec.ts @@ -0,0 +1,105 @@ +import cartActions from '@vue-storefront/core/modules/cart/store/actions'; +import { createContextMock } from '@vue-storefront/unit-tests/utils'; + +jest.mock('@vue-storefront/core/store', () => ({ + dispatch: jest.fn(), + state: {} +})); +jest.mock('js-sha3', () => ({ sha3_224: jest.fn() })); +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('config', () => ({})); +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(), + localizedRoute: jest.fn() +})); +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/data-resolver', () => ({ + CartService: { + applyCoupon: async () => ({ result: true }), + removeCoupon: async () => ({ result: true }), + getPaymentMethods: jest.fn(), + updateItem: jest.fn(), + getShippingMethods: jest.fn() + } +})); +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ + StorageManager: { + get: jest.fn() + } +})); +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() +})); +jest.mock('@vue-storefront/core/modules/cart/helpers', () => ({ + prepareProductsToAdd: jest.fn(), + productsEquals: jest.fn(), + validateProduct: jest.fn(), + notifications: { + createNotifications: jest.fn() + }, + createCartItemForUpdate: jest.fn(), + createDiffLog: jest.fn(() => ({ + pushNotifications: jest.fn(), + pushServerResponse: jest.fn(), + pushServerParty: jest.fn(), + pushClientParty: jest.fn(), + merge: jest.fn(), + isEmpty: jest.fn() + })), + preparePaymentMethodsToSync: jest.fn() +})); +jest.mock('@vue-storefront/core/helpers', () => ({ + get isServer () { + return true; + }, + onlineHelper: { + get isOnline () { + return true; + } + }, + processLocalizedURLAddress: url => url +})); + +describe('Cart productActions', () => { + it('finds configurable children', async () => { + const serverItem = { sku: 1, name: 'product1', product_type: 'configurable' } + const contextMock = createContextMock(); + + (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(result).toEqual({ childSku: 1, sku: 1 }) + }); + + it('finds product variant', async () => { + const serverItem = { sku: 1, name: 'product1', product_type: 'configurable', qty: 1, quote_id: 1, item_id: 1, product_option: 'opt1' } + const contextMock = createContextMock(); + + (contextMock.dispatch as jest.Mock).mockImplementationOnce(() => ({})); + (contextMock.dispatch as jest.Mock).mockImplementationOnce(() => ({ sku: 1, name: 'product1', product_type: 'configurable', opt1: 1 })); + + 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(result).toEqual({ + name: 'product1', + opt1: 1, + product_option: 'opt1', + product_type: 'configurable', + qty: 1, + server_cart_id: 1, + server_item_id: 1, + sku: 1 + }) + }); +}); diff --git a/core/modules/cart/test/unit/store/quantityActions.spec.ts b/core/modules/cart/test/unit/store/quantityActions.spec.ts new file mode 100644 index 000000000..f41a9c3ff --- /dev/null +++ b/core/modules/cart/test/unit/store/quantityActions.spec.ts @@ -0,0 +1,110 @@ +import * as types from '@vue-storefront/core/modules/cart/store/mutation-types'; +import cartActions from '@vue-storefront/core/modules/cart/store/actions'; +import { createContextMock } from '@vue-storefront/unit-tests/utils'; + +jest.mock('@vue-storefront/core/store', () => ({ + dispatch: jest.fn(), + state: {} +})); +jest.mock('js-sha3', () => ({ sha3_224: jest.fn() })); +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('config', () => ({})); +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(), + localizedRoute: jest.fn() +})); +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/data-resolver', () => ({ + CartService: { + applyCoupon: async () => ({ result: true }), + removeCoupon: async () => ({ result: true }), + getPaymentMethods: jest.fn(), + updateItem: jest.fn(), + getShippingMethods: jest.fn() + } +})); +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ + StorageManager: { + get: jest.fn() + } +})); +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() +})); +jest.mock('@vue-storefront/core/modules/cart/helpers', () => ({ + prepareProductsToAdd: jest.fn(), + productsEquals: jest.fn(), + validateProduct: jest.fn(), + notifications: { + createNotifications: jest.fn() + }, + createCartItemForUpdate: jest.fn(), + createDiffLog: jest.fn(() => ({ + pushNotifications: jest.fn(), + pushServerResponse: jest.fn(), + pushServerParty: jest.fn(), + pushClientParty: jest.fn(), + merge: jest.fn(), + isEmpty: jest.fn() + })), + preparePaymentMethodsToSync: jest.fn() +})); +jest.mock('@vue-storefront/core/helpers', () => ({ + get isServer () { + return true; + }, + onlineHelper: { + get isOnline () { + return true; + } + }, + processLocalizedURLAddress: url => url +})); + +describe('Cart quantityActions', () => { + it('restores original quantity', async () => { + const cartItem = { sku: 1, name: 'product1', prev_qty: 2 } + const clientItem = { sku: 1, name: 'product1' } + + const contextMock = createContextMock(); + + (contextMock.dispatch as jest.Mock).mockImplementationOnce(() => cartItem); + await (cartActions as any).restoreQuantity(contextMock, { cartItem, clientItem }) + expect(contextMock.dispatch).toBeCalledWith('updateItem', { product: { qty: 2 } }) + }); + + it('removes item that has not quantity', async () => { + const cartItem = { sku: 1, name: 'product1' } + const clientItem = { sku: 1, name: 'product1' } + + const contextMock = createContextMock(); + + (contextMock.dispatch as jest.Mock).mockImplementationOnce(() => cartItem); + await (cartActions as any).restoreQuantity(contextMock, { cartItem, clientItem }); + expect(contextMock.dispatch).toBeCalledWith('removeItem', { product: cartItem, removeByParentSku: false }) + }); + + it('updates quantity', async () => { + const product = { sku: 1, name: 'product1', qty: 2, server_item_id: 1 } + + const contextMock = createContextMock({ + getters: { + isCartSyncEnabled: true + } + }); + + await (cartActions as any).updateQuantity(contextMock, { product, qty: 3 }); + expect(contextMock.commit).toBeCalledWith(types.CART_UPD_ITEM, { product, qty: 3 }) + expect(contextMock.dispatch).toBeCalledWith('sync', { forceClientState: true }) + }) +}); diff --git a/core/modules/cart/test/unit/store/synchronizeActions.spec.ts b/core/modules/cart/test/unit/store/synchronizeActions.spec.ts new file mode 100644 index 000000000..918acc978 --- /dev/null +++ b/core/modules/cart/test/unit/store/synchronizeActions.spec.ts @@ -0,0 +1,240 @@ +import * as types from '@vue-storefront/core/modules/cart/store/mutation-types'; +import config from 'config'; +import { StorageManager } from '@vue-storefront/core/lib/storage-manager'; +import { CartService } from '@vue-storefront/core/data-resolver'; +import cartActions from '@vue-storefront/core/modules/cart/store/actions'; +import { createContextMock } from '@vue-storefront/unit-tests/utils'; + +jest.mock('@vue-storefront/core/store', () => ({ + dispatch: jest.fn(), + state: {} +})); +jest.mock('js-sha3', () => ({ sha3_224: jest.fn() })); +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('config', () => ({})); +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(), + localizedRoute: jest.fn() +})); +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/data-resolver', () => ({ + CartService: { + applyCoupon: async () => ({ result: true }), + removeCoupon: async () => ({ result: true }), + getPaymentMethods: jest.fn(), + updateItem: jest.fn(), + getShippingMethods: jest.fn(), + getItems: jest.fn() + } +})); +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ + StorageManager: { + get: jest.fn() + } +})); +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() +})); +jest.mock('@vue-storefront/core/modules/cart/helpers', () => ({ + prepareProductsToAdd: jest.fn(), + productsEquals: jest.fn(), + validateProduct: jest.fn(), + notifications: { + createNotifications: jest.fn() + }, + createCartItemForUpdate: jest.fn(), + createDiffLog: jest.fn(() => ({ + pushNotifications: jest.fn(), + pushServerResponse: jest.fn(), + pushServerParty: jest.fn(), + pushClientParty: jest.fn(), + merge: jest.fn(), + isEmpty: jest.fn() + })), + preparePaymentMethodsToSync: jest.fn() +})); +jest.mock('@vue-storefront/core/helpers', () => ({ + get isServer () { + return false; + }, + onlineHelper: { + get isOnline () { + return true; + } + }, + processLocalizedURLAddress: url => url +})); + +describe('Cart synchronizeActions', () => { + it('loads current cart', async () => { + const contextMock = createContextMock(); + + (StorageManager.get as jest.Mock).mockImplementation(() => ({ + getItem: async () => ({}) + })) + await (cartActions as any).load(contextMock, {}); + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'setDefaultCheckoutMethods') + expect(contextMock.commit).toBeCalledWith(types.CART_LOAD_CART, {}) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(2, 'synchronizeCart', { forceClientState: false }) + }); + + it('synchronizes cart', async () => { + (StorageManager.get as jest.Mock).mockImplementation(() => ({ + getItem: async () => 'hash-token' + })) + + config.cart = { + synchronize: true, + serverMergeByDefault: false + } + const contextMock = createContextMock(); + + await (cartActions as any).synchronizeCart(contextMock, { forceClientState: false }); + expect(contextMock.commit).toHaveBeenNthCalledWith(1, types.CART_SET_ITEMS_HASH, 'hash-token') + expect(contextMock.commit).toHaveBeenNthCalledWith(2, types.CART_LOAD_CART_SERVER_TOKEN, 'hash-token') + expect(contextMock.dispatch).toBeCalledWith('sync', { forceClientState: false, dryRun: true }) + }) + + it('creates a new cart token', async () => { + (StorageManager.get as jest.Mock).mockImplementation(() => ({ + getItem: async () => null + })) + + config.cart = { + synchronize: true, + serverMergeByDefault: false + } + const contextMock = createContextMock(); + + await (cartActions as any).synchronizeCart(contextMock, { forceClientState: false }); + expect(contextMock.commit).not.toHaveBeenNthCalledWith(1, types.CART_SET_ITEMS_HASH, 'hash-token') + expect(contextMock.commit).not.toHaveBeenNthCalledWith(2, types.CART_LOAD_CART_SERVER_TOKEN, 'hash-token') + expect(contextMock.dispatch).toBeCalledWith('connect', { guestCart: false }) + }) + + it('merges current cart', async () => { + (CartService.getItems as jest.Mock).mockImplementation(async () => ({ + resultCode: 200, + result: [] + })) + + const contextMock = createContextMock({ + rootGetters: { + 'checkout/isUserInCheckout': true + }, + getters: { + getCartItems: [], + canUpdateMethods: true, + isSyncRequired: true, + bypassCounter: 0 + } + }); + await (cartActions as any).sync(contextMock, {}); + expect(contextMock.dispatch).toBeCalledWith('merge', { + clientItems: [], + dryRun: false, + forceClientState: true, + serverItems: [] + }) + }) + + it('attempts to bypass guest cart', async () => { + (CartService.getItems as jest.Mock).mockImplementation(async () => ({ + resultCode: 500, + result: null + })) + + config.queues = { + maxCartBypassAttempts: 4 + } + + const contextMock = createContextMock({ + rootGetters: { + 'checkout/isUserInCheckout': true + }, + getters: { + getCartItems: [], + canUpdateMethods: true, + isSyncRequired: true, + bypassCounter: 0 + } + }); + + await (cartActions as any).sync(contextMock, {}); + expect(contextMock.dispatch).toBeCalledWith('connect', { guestCart: true }) + }) + + it('removes product when there is out of stock', async () => { + const product = { sku: 1, name: 'product1' } + const stockTask = { sku: 1, product_sku: 1, result: { is_in_stock: false, code: 'ok' } } + config.stock = { + allowOutOfStockInCart: false + } + config.cart = { + synchronize: false + } + const contextMock = createContextMock(); + + (contextMock.dispatch as jest.Mock).mockImplementation(() => product) + + await (cartActions as any).stockSync(contextMock, stockTask); + expect(contextMock.dispatch).toBeCalledWith('getItem', { product: { sku: 1 } }) + expect(contextMock.commit).toBeCalledWith(types.CART_DEL_ITEM, { product: { sku: 1 } }, { root: true }) + }) + it('triggers an error when there is out of stock', async () => { + const product = { sku: 1, name: 'product1' } + const stockTask = { sku: 1, product_sku: 1, result: { is_in_stock: false, code: 'ok' } } + config.stock = { + allowOutOfStockInCart: true + } + config.cart = { + synchronize: false + } + const contextMock = createContextMock(); + + (contextMock.dispatch as jest.Mock).mockImplementationOnce(() => product) + + await (cartActions as any).stockSync(contextMock, stockTask); + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'getItem', { product: { sku: 1 } }) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(2, 'updateItem', { + product: { + errors: { stock: 'Out of the stock!' }, + is_in_stock: false, + sku: 1 + } + }) + }) + it('shows that product is in stock', async () => { + const product = { sku: 1, name: 'product1' } + const stockTask = { sku: 1, product_sku: 1, result: { is_in_stock: true, code: 'ok' } } + config.stock = { + allowOutOfStockInCart: true + } + config.cart = { + synchronize: false + } + const contextMock = createContextMock(); + + (contextMock.dispatch as jest.Mock).mockImplementationOnce(() => product) + + await (cartActions as any).stockSync(contextMock, stockTask); + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'getItem', { product: { sku: 1 } }) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(2, 'updateItem', { + product: { + info: { stock: 'In stock!' }, + is_in_stock: true, + sku: 1 + } + }) + }) +}); diff --git a/core/modules/cart/test/unit/store/totalsActions.spec.ts b/core/modules/cart/test/unit/store/totalsActions.spec.ts new file mode 100644 index 000000000..c461cb8c0 --- /dev/null +++ b/core/modules/cart/test/unit/store/totalsActions.spec.ts @@ -0,0 +1,130 @@ +import * as types from '@vue-storefront/core/modules/cart/store/mutation-types'; +import { + prepareShippingInfoForUpdateTotals, + createOrderData, + createShippingInfoData +} 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'; + +jest.mock('@vue-storefront/core/store', () => ({ + dispatch: jest.fn(), + state: {} +})); +jest.mock('js-sha3', () => ({ sha3_224: jest.fn() })); +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('config', () => ({})); +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(), + localizedRoute: jest.fn() +})); +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/data-resolver', () => ({ + CartService: { + applyCoupon: async () => ({ result: true }), + removeCoupon: async () => ({ result: true }), + getPaymentMethods: jest.fn(), + updateItem: jest.fn(), + getShippingMethods: jest.fn(), + getItems: jest.fn() + } +})); +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ + StorageManager: { + get: jest.fn() + } +})); +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() +})); +jest.mock('@vue-storefront/core/modules/cart/helpers', () => ({ + prepareProductsToAdd: jest.fn(), + productsEquals: jest.fn(), + validateProduct: jest.fn(), + notifications: { + createNotifications: jest.fn() + }, + createCartItemForUpdate: jest.fn(), + createDiffLog: jest.fn(() => ({ + pushNotifications: jest.fn(), + pushServerResponse: jest.fn(), + pushServerParty: jest.fn(), + pushClientParty: jest.fn(), + merge: jest.fn(), + isEmpty: jest.fn() + })), + preparePaymentMethodsToSync: jest.fn(), + prepareShippingInfoForUpdateTotals: jest.fn(), + createOrderData: jest.fn(), + createShippingInfoData: jest.fn() +})); +jest.mock('@vue-storefront/core/helpers', () => ({ + get isServer () { + return false; + }, + onlineHelper: { + get isOnline () { + return true; + } + }, + processLocalizedURLAddress: url => url +})); + +describe('Cart totalsActions', () => { + it('replaces server totals', async () => { + const itemsAfterTotal = { + key1: { qty: 1, param1: 1, param2: 2, item_id: 1 }, + key2: { qty: 3, param1: 3, param2: 5, item_id: 2 }, + key3: { qty: 5, param1: 1, param2: 6, item_id: 3 } + } + const totals = { total_segments: {} } + const contextMock = createContextMock(); + + (contextMock.dispatch as jest.Mock).mockImplementationOnce(async () => ({ + resultCode: 200, + result: { totals } + })); + (prepareShippingInfoForUpdateTotals as jest.Mock).mockImplementation(() => itemsAfterTotal); + + await (cartActions as any).overrideServerTotals(contextMock, { addressInformation: {}, hasShippingInformation: true }); + expect(contextMock.dispatch).toHaveBeenNthCalledWith(2, 'updateItem', { + product: { qty: 1, server_item_id: 1, totals: { item_id: 1, param1: 1, param2: 2, qty: 1 } } + }) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(3, 'updateItem', { + product: { qty: 3, server_item_id: 2, totals: { item_id: 2, param1: 3, param2: 5, qty: 3 } } + }) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(4, 'updateItem', { + product: { qty: 5, server_item_id: 3, totals: { item_id: 3, param1: 1, param2: 6, qty: 5 } } + }) + expect(contextMock.commit).toHaveBeenNthCalledWith(1, types.CART_UPD_TOTALS, { itemsAfterTotal, totals, platformTotalSegments: totals.total_segments }) + }); + + it('synchronizes totals', async () => { + (createOrderData as jest.Mock).mockImplementation(() => ({ country: 'US', method_code: 'XXX' })); + (createShippingInfoData as jest.Mock).mockImplementation(() => 'address information'); + const contextMock = createContextMock({ + rootGetters: { + 'checkout/getShippingDetails': {}, + 'checkout/getShippingMethods': {}, + 'checkout/getPaymentMethods': {} + }, + getters: { + canSyncTotals: true, + isTotalsSyncRequired: true + } + }); + await (cartActions as any).syncTotals(contextMock, {}); + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'pullMethods', {}) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(2, 'overrideServerTotals', { hasShippingInformation: 'XXX', addressInformation: 'address information' }) + }) +}); diff --git a/core/modules/cart/types/BillingAddress.ts b/core/modules/cart/types/BillingAddress.ts new file mode 100644 index 000000000..be0bae3f4 --- /dev/null +++ b/core/modules/cart/types/BillingAddress.ts @@ -0,0 +1,8 @@ +export default interface BillingAddress { + firstname: string, + lastname: string, + city: string, + postcode: string, + street: string[], + countryId: string +} diff --git a/core/modules/cart/types/CartItem.ts b/core/modules/cart/types/CartItem.ts index 3a44681d2..395cf363d 100644 --- a/core/modules/cart/types/CartItem.ts +++ b/core/modules/cart/types/CartItem.ts @@ -7,6 +7,10 @@ export default interface CartItem extends Product { qty: number, options: CartItemOption[], totals: CartItemTotals, - server_item_id: number, - server_cart_id: any + server_item_id: number | string, + server_cart_id: any, + product_type?: string, + item_id?: number | string, + checksum?: string, + quoteId?: string } diff --git a/core/modules/cart/types/CartItemTotals.ts b/core/modules/cart/types/CartItemTotals.ts index 80608fdbd..cec1756d3 100644 --- a/core/modules/cart/types/CartItemTotals.ts +++ b/core/modules/cart/types/CartItemTotals.ts @@ -10,7 +10,7 @@ export default interface CartItemTotals { base_tax_amount: number, discount_amount: number, discount_percent: number, - item_id: number, + item_id: number | string, name: string, options: CartItemOption[], price: number, diff --git a/core/modules/cart/types/CartState.ts b/core/modules/cart/types/CartState.ts index 6bb5a6fb5..5db0c3435 100644 --- a/core/modules/cart/types/CartState.ts +++ b/core/modules/cart/types/CartState.ts @@ -10,5 +10,7 @@ export default interface CartState { cartItemsHash: string, cartServerLastSyncDate: number, cartServerLastTotalsSyncDate: number, - cartItems: any[] + cartItems: any[], + connectBypassCount: number, + isAddingToCart: boolean } diff --git a/core/modules/cart/types/CheckoutData.ts b/core/modules/cart/types/CheckoutData.ts new file mode 100644 index 000000000..4e2c5c676 --- /dev/null +++ b/core/modules/cart/types/CheckoutData.ts @@ -0,0 +1,12 @@ +import ShippingDetails from '@vue-storefront/core/modules/checkout/types/ShippingDetails' +import ShippingMethod from './ShippingMethod' +import PaymentMethod from './PaymentMethod' +import PaymentDetails from '@vue-storefront/core/modules/checkout/types/PaymentDetails' + +export default interface CheckoutData { + shippingDetails: ShippingDetails, + shippingMethods: ShippingMethod[], + paymentMethods: PaymentMethod[], + paymentDetails: PaymentDetails, + taxCountry?: string +} diff --git a/core/modules/cart/types/DiffLog.ts b/core/modules/cart/types/DiffLog.ts new file mode 100644 index 000000000..0e2991495 --- /dev/null +++ b/core/modules/cart/types/DiffLog.ts @@ -0,0 +1,18 @@ +export interface Notification { + type: string, + message: any, + action1: any, + action2?: any +} + +export interface ServerResponse { + status: string | number, + sku: string, + result: any +} + +export interface Party { + party: string, + status: string, + sku: string +} diff --git a/core/modules/cart/types/OrderShippingDetails.ts b/core/modules/cart/types/OrderShippingDetails.ts new file mode 100644 index 000000000..ebfc2005c --- /dev/null +++ b/core/modules/cart/types/OrderShippingDetails.ts @@ -0,0 +1,11 @@ +import ShippingAddress from './ShippingAddress' +import BillingAddress from './BillingAddress' + +export default interface OrderShippingDetails { + country?: string, + method_code?: string, + carrier_code?: string, + payment_method?: string, + shippingAddress?: ShippingAddress, + billingAddress?: BillingAddress +} diff --git a/core/modules/cart/types/PaymentMethod.ts b/core/modules/cart/types/PaymentMethod.ts new file mode 100644 index 000000000..cb86869fe --- /dev/null +++ b/core/modules/cart/types/PaymentMethod.ts @@ -0,0 +1,6 @@ +export default interface PaymentMethod { + default: boolean, + code?: string, + cost_incl_tax?: number, + title?: string +} diff --git a/core/modules/cart/types/ShippingAddress.ts b/core/modules/cart/types/ShippingAddress.ts new file mode 100644 index 000000000..a0e200c0f --- /dev/null +++ b/core/modules/cart/types/ShippingAddress.ts @@ -0,0 +1,7 @@ +export default interface ShippingAddress { + firstname: string, + lastname: string, + city: string, + postcode: string, + street: string[] +} diff --git a/core/modules/cart/types/ShippingMethod.ts b/core/modules/cart/types/ShippingMethod.ts new file mode 100644 index 000000000..1620052bb --- /dev/null +++ b/core/modules/cart/types/ShippingMethod.ts @@ -0,0 +1,8 @@ +export default interface ShippingMethod { + method_code?: string, + carrier_code?: string, + offline: boolean, + default: boolean, + price_incl_tax?: number, + method_title?: string +} diff --git a/core/modules/cart/types/Totals.ts b/core/modules/cart/types/Totals.ts new file mode 100644 index 000000000..68c99e731 --- /dev/null +++ b/core/modules/cart/types/Totals.ts @@ -0,0 +1,10 @@ +export default interface Totals { + item_id?: number | string, + options?: string, + name: string, + qty: number, + row_total: number, + row_total_incl_tax: number, + tax_amount: number, + tax_percent: number +} diff --git a/core/modules/catalog-next/helpers/cacheProductsHelper.ts b/core/modules/catalog-next/helpers/cacheProductsHelper.ts new file mode 100644 index 000000000..d5fe9a83d --- /dev/null +++ b/core/modules/catalog-next/helpers/cacheProductsHelper.ts @@ -0,0 +1,24 @@ +import { products } from 'config' + +export const prefetchStockItems = (cachedProductsResponse, cache = {}) => { + const skus = [] + let prefetchIndex = 0 + cachedProductsResponse.items.map(i => { + if (products.configurableChildrenStockPrefetchStatic && + products.configurableChildrenStockPrefetchStaticPrefetchCount > 0) { + if (prefetchIndex > products.configurableChildrenStockPrefetchStaticPrefetchCount) return + } + skus.push(i.sku) // main product sku to be checked anyway + if (i.type_id === 'configurable' && i.configurable_children && i.configurable_children.length > 0) { + for (const confChild of i.configurable_children) { + const cachedItem = cache[confChild.id] + if (typeof cachedItem === 'undefined' || cachedItem === null) { + skus.push(confChild.sku) + } + } + prefetchIndex++ + } + }) + + return skus +} diff --git a/core/modules/catalog-next/helpers/categoryHelpers.ts b/core/modules/catalog-next/helpers/categoryHelpers.ts new file mode 100644 index 000000000..98a3b7252 --- /dev/null +++ b/core/modules/catalog-next/helpers/categoryHelpers.ts @@ -0,0 +1,28 @@ +import { entities } from 'config' +import { Category, ChildrenData } from '../types/Category' + +export const compareByLabel = (a, b) => { + if (a.label < b.label) { + return -1 + } + if (a.label > b.label) { + return 1 + } + return 0 +} + +export const _prepareCategoryPathIds = (category: Category): string[] => { + if (!category || !category.path) return [] + return category.path.split('/') +} + +export const getSearchOptionsFromRouteParams = (params: { [key: string]: string } = {}): Record => { + const filterableKeys = entities.category.validSearchOptionsFromRouteParams + let filters: { [key: string]: string } = {} + + Object.keys(params) + .filter(key => filterableKeys.includes(key)) + .forEach(key => { filters[key] = params[key] }) + + return filters +} diff --git a/core/modules/catalog-next/helpers/filterHelpers.ts b/core/modules/catalog-next/helpers/filterHelpers.ts new file mode 100644 index 000000000..9981aba2b --- /dev/null +++ b/core/modules/catalog-next/helpers/filterHelpers.ts @@ -0,0 +1,59 @@ +import config from 'config' +import FilterVariant from 'core/modules/catalog-next/types/FilterVariant' + +export const getSystemFilterNames: string[] = config.products.systemFilterNames + +/** + * Creates new filtersQuery (based on currentQuery) by modifying specific filter variant. + */ +export const changeFilterQuery = ({currentQuery = {}, filterVariant}: {currentQuery?: any, filterVariant?: FilterVariant} = {}) => { + const newQuery = JSON.parse(JSON.stringify(currentQuery)) + if (!filterVariant) return newQuery + if (getSystemFilterNames.includes(filterVariant.type)) { + if (newQuery[filterVariant.type] && newQuery[filterVariant.type] === filterVariant.id) { + delete newQuery[filterVariant.type] + } else { + newQuery[filterVariant.type] = filterVariant.id + } + } else { + let queryFilter = newQuery[filterVariant.type] || [] + if (!Array.isArray(queryFilter)) queryFilter = [queryFilter] + if (queryFilter.includes(filterVariant.id)) { + queryFilter = queryFilter.filter(value => value !== filterVariant.id) + } else if (filterVariant.single) { + queryFilter = [filterVariant.id] + } else { + queryFilter.push(filterVariant.id) + } + // delete or add filter variant to query + if (!queryFilter.length) delete newQuery[filterVariant.type] + else newQuery[filterVariant.type] = queryFilter + } + return newQuery +} + +export const getFiltersFromQuery = ({filtersQuery = {}, availableFilters = {}} = {}) => { + const searchQuery = { + filters: {} + } + Object.keys(filtersQuery).forEach(filterKey => { + const filter = availableFilters[filterKey] + const queryValue = filtersQuery[filterKey] + if (!filter) return + if (getSystemFilterNames.includes(filterKey)) { + searchQuery[filterKey] = queryValue + } else if (Array.isArray(queryValue)) { + queryValue.map(singleValue => { + const variant = filter.find(filterVariant => filterVariant.id === singleValue) + if (!variant) return + if (!searchQuery.filters[filterKey] || !Array.isArray(searchQuery.filters[filterKey])) searchQuery.filters[filterKey] = [] + searchQuery.filters[filterKey].push({...variant, attribute_code: filterKey}) + }) + } else { + const variant = filter.find(filterVariant => filterVariant.id === queryValue) + if (!variant) return + searchQuery.filters[filterKey] = {...variant, attribute_code: filterKey} + } + }) + return searchQuery +} diff --git a/core/modules/catalog-next/helpers/optionLabel.ts b/core/modules/catalog-next/helpers/optionLabel.ts new file mode 100644 index 000000000..49d3ed76f --- /dev/null +++ b/core/modules/catalog-next/helpers/optionLabel.ts @@ -0,0 +1,4 @@ +import { optionLabel } from '@vue-storefront/core/modules/catalog/helpers/optionLabel' + +// TODO in future move old helper here, add tests and refactor +export { optionLabel } diff --git a/core/modules/catalog-next/hooks.ts b/core/modules/catalog-next/hooks.ts new file mode 100644 index 000000000..c7b2a343f --- /dev/null +++ b/core/modules/catalog-next/hooks.ts @@ -0,0 +1,36 @@ +import { createListenerHook, createMutatorHook } from '@vue-storefront/core/lib/hooks' +import { Category } from './types/Category'; + +const { + hook: categoryPageVisitedHook, + executor: categoryPageVisitedExecutor +} = createListenerHook() + +const { + hook: productPageVisitedHook, + executor: productPageVisitedExecutor +} = createListenerHook() + +/** Only for internal usage */ +const catalogHooksExecutors = { + categoryPageVisited: categoryPageVisitedExecutor, + productPageVisited: productPageVisitedExecutor +} + +const catalogHooks = { + /** + * Hook is fired right after category page is visited. + * @param category visited category + */ + categoryPageVisited: categoryPageVisitedHook, + /** + * Hook is fired right after product page is visited. + * @param product visited product + */ + productPageVisited: productPageVisitedHook +} + +export { + catalogHooks, + catalogHooksExecutors +} diff --git a/core/modules/catalog-next/index.ts b/core/modules/catalog-next/index.ts new file mode 100644 index 000000000..0e0c4b4a5 --- /dev/null +++ b/core/modules/catalog-next/index.ts @@ -0,0 +1,6 @@ +import { categoryModule } from './store/category' +import { StorefrontModule } from '@vue-storefront/core/lib/modules'; + +export const CatalogNextModule: StorefrontModule = function ({store}) { + store.registerModule('category-next', categoryModule) +} diff --git a/core/modules/catalog-next/store/category/CategoryState.ts b/core/modules/catalog-next/store/category/CategoryState.ts new file mode 100644 index 000000000..d67909ec5 --- /dev/null +++ b/core/modules/catalog-next/store/category/CategoryState.ts @@ -0,0 +1,10 @@ +import { Category } from '../../types/Category'; +import Product from 'core/modules/catalog/types/Product'; + +export default interface CategoryState { + categoriesMap: { [id: string]: Category }, + notFoundCategoryIds: string[], + filtersMap: { [id: string]: any }, + products: Product[], + searchProductsStats: any +} diff --git a/core/modules/catalog-next/store/category/actions.ts b/core/modules/catalog-next/store/category/actions.ts new file mode 100644 index 000000000..2d6cd0650 --- /dev/null +++ b/core/modules/catalog-next/store/category/actions.ts @@ -0,0 +1,214 @@ +// import Vue from 'vue' +import { ActionTree } from 'vuex' +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 { router } from '@vue-storefront/core/app' +import { currentStoreView, localizedDispatcherRoute, localizedDispatcherRouteName } 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' + +const actions: ActionTree = { + async loadCategoryProducts ({ commit, getters, dispatch, rootState }, { route, category, pageSize = 50 } = {}) { + const searchCategory = category || getters.getCategoryFrom(route.path) || {} + const categoryMappedFilters = getters.getFiltersMap[searchCategory.id] + const areFiltersInQuery = !!Object.keys(route[products.routerFiltersSource]).length + if (!categoryMappedFilters && areFiltersInQuery) { // loading all filters only when some filters are currently chosen and category has no available filters yet + await dispatch('loadCategoryFilters', searchCategory) + } + const searchQuery = getters.getCurrentFiltersFrom(route[products.routerFiltersSource], categoryMappedFilters) + let filterQr = buildFilterProductsQuery(searchCategory, searchQuery.filters) + const {items, perPage, start, total, aggregations} = await quickSearchByQuery({ + query: filterQr, + sort: searchQuery.sort || `${products.defaultSortBy.attribute}:${products.defaultSortBy.order}`, + includeFields: entities.productList.includeFields, + excludeFields: entities.productList.excludeFields, + size: pageSize + }) + 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) + + return items + }, + async loadMoreCategoryProducts ({ commit, getters, rootState, dispatch }) { + const { perPage, start, total } = getters.getCategorySearchProductsStats + const totalValue = typeof total === 'object' ? total.value : total + if (start >= totalValue || totalValue < perPage) return + + const searchQuery = getters.getCurrentSearchQuery + let filterQr = buildFilterProductsQuery(getters.getCurrentCategory, searchQuery.filters) + const searchResult = await quickSearchByQuery({ + query: filterQr, + sort: searchQuery.sort || `${products.defaultSortBy.attribute}:${products.defaultSortBy.order}`, + start: start + perPage, + size: perPage, + includeFields: entities.productList.includeFields, + excludeFields: entities.productList.excludeFields + }) + 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) + + return searchResult.items + }, + async cacheProducts ({ commit, getters, dispatch, rootState }, { route } = {}) { + 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 + query: filterQr, + sort: searchQuery.sort, + updateState: false // not update the product listing - this request is only for caching + }, { root: true }) + if (products.filterUnavailableVariants) { // prefetch the stock items + const skus = prefetchStockItems(cachedProductsResponse, rootState.stock.cache) + + for (const chunkItem of chunk(skus, 15)) { + dispatch('stock/list', { skus: chunkItem }, { root: true }) // store it in the cache + } + } + }, + /** + * Calculates products taxes + * Registers URLs + * Configures products + */ + async processCategoryProducts ({ dispatch, rootState }, { products = [], filters = {} } = {}) { + await dispatch('tax/calculateTaxes', { products: products }, { root: true }) + dispatch('registerCategoryProductsMapping', products) // we don't need to wait for this + return dispatch('configureProducts', { products, filters }) + }, + /** + * 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 = {} } = {}) { + return products.map(product => { + product = Object.assign({}, preConfigureProduct({ product, populateRequestCacheTags: config.server.useOutputCacheTagging })) + 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) + }, + async loadCategories ({ commit, getters }, categorySearchOptions: DataResolver.CategorySearchOptions): Promise { + const searchingByIds = !(!categorySearchOptions || !categorySearchOptions.filters || !categorySearchOptions.filters.id) + const searchedIds: string[] = searchingByIds ? [...categorySearchOptions.filters.id].map(String) : [] + const loadedCategories: Category[] = [] + if (searchingByIds && !categorySearchOptions.reloadAll) { // removing from search query already loaded categories, they are added to returned results + for (const [categoryId, category] of Object.entries(getters.getCategoriesMap)) { + if (searchedIds.includes(categoryId)) { + loadedCategories.push(category as Category) + } + } + categorySearchOptions.filters.id = searchedIds.filter(categoryId => !getters.getCategoriesMap[categoryId] && !getters.getNotFoundCategoryIds.includes(categoryId)) + } + if (!searchingByIds || categorySearchOptions.filters.id.length) { + categorySearchOptions.filters = Object.assign(cloneDeep(config.entities.category.filterFields), categorySearchOptions.filters ? cloneDeep(categorySearchOptions.filters) : {}) + const categories = await CategoryService.getCategories(categorySearchOptions) + const notFoundCategories = searchedIds.filter(categoryId => !categories.some(cat => cat.id === parseInt(categoryId))) + + commit(types.CATEGORY_ADD_CATEGORIES, categories) + commit(types.CATEGORY_ADD_NOT_FOUND_CATEGORY_IDS, notFoundCategories) + return [...loadedCategories, ...categories] + } + return loadedCategories + }, + async loadCategory ({ commit }, categorySearchOptions: DataResolver.CategorySearchOptions): Promise { + const categories: Category[] = await CategoryService.getCategories(categorySearchOptions) + const category: Category = categories && categories.length ? categories[0] : null + commit(types.CATEGORY_ADD_CATEGORY, category) + return category + }, + /** + * Fetch and process filters from current category and sets them in available filters. + */ + async loadCategoryFilters ({ dispatch, getters }, category) { + const searchCategory = category || getters.getCurrentCategory + let filterQr = buildFilterProductsQuery(searchCategory) + const {aggregations} = await quickSearchByQuery({ + query: filterQr, + size: config.products.maxFiltersQuerySize, + excludeFields: ['*'] + }) + await dispatch('loadAvailableFiltersFrom', {aggregations, category}) + }, + async loadAvailableFiltersFrom ({ commit, getters }, {aggregations, category, filters = {}}) { + const aggregationFilters = getters.getAvailableFiltersFrom(aggregations) + const currentCategory = category || getters.getCurrentCategory + const categoryMappedFilters = getters.getFiltersMap[currentCategory.id] + let resultFilters = aggregationFilters + const filtersKeys = Object.keys(filters) + if (categoryMappedFilters && filtersKeys.length) { + resultFilters = Object.assign(cloneDeep(categoryMappedFilters), cloneDeep(omit(aggregationFilters, filtersKeys))) + } + commit(types.CATEGORY_SET_CATEGORY_FILTERS, {category, filters: resultFilters}) + }, + async switchSearchFilters ({ dispatch }, filterVariants: FilterVariant[] = []) { + let currentQuery = router.currentRoute[products.routerFiltersSource] + filterVariants.forEach(filterVariant => { + currentQuery = changeFilterQuery({currentQuery, filterVariant}) + }) + await dispatch('changeRouterFilterParameters', currentQuery) + }, + async resetSearchFilters ({dispatch}) { + await dispatch('changeRouterFilterParameters', {}) + }, + async changeRouterFilterParameters (context, query) { + router.push({[products.routerFiltersSource]: query}) + }, + async loadCategoryBreadcrumbs ({ dispatch, getters }, { category, currentRouteName, omitCurrent = false }) { + if (!category) return + const categoryHierarchyIds = _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 = [] + for (const id of categoryHierarchyIds) { + const index = categories.findIndex(cat => cat.id.toString() === id) + if (index >= 0 && (!omitCurrent || categories[index].id !== category.id)) { + sorted.push(categories[index]) + } + } + await dispatch('breadcrumbs/set', { current: currentRouteName, routes: parseCategoryPath(sorted) }, { root: true }) + return sorted + } +} + +export default actions diff --git a/core/modules/catalog-next/store/category/getters.ts b/core/modules/catalog-next/store/category/getters.ts new file mode 100644 index 000000000..364142bdb --- /dev/null +++ b/core/modules/catalog-next/store/category/getters.ts @@ -0,0 +1,141 @@ +import { nonReactiveState } from './index'; +import { GetterTree } from 'vuex' +import RootState from '@vue-storefront/core/types/RootState' +import CategoryState from './CategoryState' +import { compareByLabel } from '../../helpers/categoryHelpers' +import { products } from 'config' +import FilterVariant from '../../types/FilterVariant' +import { optionLabel } from '../../helpers/optionLabel' +import trim from 'lodash-es/trim' +import toString from 'lodash-es/toString' +import forEach from 'lodash-es/forEach' +import { getFiltersFromQuery } from '../../helpers/filterHelpers' +import { Category } from '../../types/Category' +import { parseCategoryPath } from '@vue-storefront/core/modules/breadcrumbs/helpers' +import { _prepareCategoryPathIds, getSearchOptionsFromRouteParams } from '../../helpers/categoryHelpers'; +import { removeStoreCodeFromRoute } from '@vue-storefront/core/lib/multistore' +import cloneDeep from 'lodash-es/cloneDeep' + +function mapCategoryProducts (productsFromState, productsData) { + return productsFromState.map(prodState => { + if (typeof prodState === 'string') { + const product = productsData.find(prodData => prodData.sku === prodState) + return cloneDeep(product) + } + return prodState + }) +} + +const getters: GetterTree = { + getCategories: (state): Category[] => Object.values(state.categoriesMap), + getCategoriesMap: (state): { [id: string]: Category} => state.categoriesMap, + getNotFoundCategoryIds: (state): string[] => state.notFoundCategoryIds, + getCategoryProducts: (state) => mapCategoryProducts(state.products, nonReactiveState.products), + getCategoryFrom: (state, getters) => (path: string = '') => { + return getters.getCategories.find(category => (removeStoreCodeFromRoute(path) as string).replace(/^(\/)/gm, '') === category.url_path) + }, + getCategoryByParams: (state, getters, rootState) => (params: { [key: string]: string } = {}) => { + return getters.getCategories.find(category => { + let valueCheck = [] + const searchOptions = getSearchOptionsFromRouteParams(params) + forEach(searchOptions, (value, key) => valueCheck.push(category[key] && category[key] === (category[key].constructor)(value))) + return valueCheck.filter(check => check === true).length === Object.keys(searchOptions).length + }) || {} + }, + getCurrentCategory: (state, getters, rootState) => { + return getters.getCategoryByParams(rootState.route.params) + }, + getAvailableFiltersFrom: (state, getters, rootState) => (aggregations) => { + const filters = {} + if (aggregations) { // populate filter aggregates + for (let attrToFilter of products.defaultFilters) { // fill out the filter options + let filterOptions: FilterVariant[] = [] + + let uniqueFilterValues = new Set() + if (attrToFilter !== 'price') { + if (aggregations['agg_terms_' + attrToFilter]) { + let buckets = aggregations['agg_terms_' + attrToFilter].buckets + if (aggregations['agg_terms_' + attrToFilter + '_options']) { + buckets = buckets.concat(aggregations['agg_terms_' + attrToFilter + '_options'].buckets) + } + + for (let option of buckets) { + uniqueFilterValues.add(toString(option.key)) + } + } + + uniqueFilterValues.forEach(key => { + const label = optionLabel(rootState.attribute, { attributeKey: attrToFilter, optionId: key }) + if (trim(label) !== '') { // is there any situation when label could be empty and we should still support it? + filterOptions.push({ + id: key, + label: label, + type: attrToFilter + }) + } + }); + filters[attrToFilter] = filterOptions.sort(compareByLabel) + } else { // special case is range filter for prices + const storeView = rootState.storeView + const currencySign = storeView.i18n.currencySign + if (aggregations['agg_range_' + attrToFilter]) { + let index = 0 + let count = aggregations['agg_range_' + attrToFilter].buckets.length + for (let option of aggregations['agg_range_' + attrToFilter].buckets) { + filterOptions.push({ + id: option.key, + type: attrToFilter, + from: option.from, + to: option.to, + label: (index === 0 || (index === count - 1)) ? (option.to ? '< ' + currencySign + option.to : '> ' + currencySign + option.from) : currencySign + option.from + (option.to ? ' - ' + option.to : ''), // TODO: add better way for formatting, extract currency sign + single: true + }) + index++ + } + filters[attrToFilter] = filterOptions + } + } + } + // Add sort to available filters + let variants = [] + Object.keys(products.sortByAttributes).map(label => { + variants.push({ + label: label, + id: products.sortByAttributes[label], + type: 'sort' + }) + }) + filters['sort'] = variants + } + return filters + }, + getFiltersMap: state => state.filtersMap, + getAvailableFilters: (state, getters) => getters.getCurrentCategory ? state.filtersMap[getters.getCurrentCategory.id] : {}, + getCurrentFiltersFrom: (state, getters, rootState) => (filters, categoryFilters) => { + const currentQuery = filters || rootState.route[products.routerFiltersSource] + const availableFilters = categoryFilters || getters.getAvailableFilters + return getFiltersFromQuery({availableFilters, filtersQuery: currentQuery}) + }, + getCurrentSearchQuery: (state, getters, rootState) => getters.getCurrentFiltersFrom(rootState.route[products.routerFiltersSource]), + getCurrentFilters: (state, getters) => getters.getCurrentSearchQuery.filters, + hasActiveFilters: (state, getters) => !!Object.keys(getters.getCurrentFilters).length, + getSystemFilterNames: () => products.systemFilterNames, + getBreadcrumbs: (state, getters) => getters.getBreadcrumbsFor(getters.getCurrentCategory), + getBreadcrumbsFor: (state, getters) => category => { + if (!category) return [] + const categoryHierarchyIds = _prepareCategoryPathIds(category) + let resultCategoryList = categoryHierarchyIds.map(categoryId => { + return getters.getCategoriesMap[categoryId] + }).filter(c => !!c) + return parseCategoryPath(resultCategoryList) + }, + getCategorySearchProductsStats: state => state.searchProductsStats || {}, + getCategoryProductsTotal: (state, getters) => { + const { total } = getters.getCategorySearchProductsStats + const totalValue = typeof total === 'object' ? total.value : total + + return totalValue || 0 + } +} + +export default getters diff --git a/core/modules/catalog-next/store/category/index.ts b/core/modules/catalog-next/store/category/index.ts new file mode 100644 index 000000000..8dd910ea1 --- /dev/null +++ b/core/modules/catalog-next/store/category/index.ts @@ -0,0 +1,24 @@ +import { Module } from 'vuex' +import actions from './actions' +import getters from './getters' +import mutations from './mutations' +import RootState from '@vue-storefront/core/types/RootState' +import CategoryState from './CategoryState' + +export const categoryModule: Module = { + namespaced: true, + state: { + categoriesMap: {}, + notFoundCategoryIds: [], + filtersMap: {}, + products: [], + searchProductsStats: {} + }, + getters, + actions, + mutations +} + +export const nonReactiveState = { + products: [] +} diff --git a/core/modules/catalog-next/store/category/mutation-types.ts b/core/modules/catalog-next/store/category/mutation-types.ts new file mode 100644 index 000000000..5e0ed5da0 --- /dev/null +++ b/core/modules/catalog-next/store/category/mutation-types.ts @@ -0,0 +1,8 @@ +export const SN_CATEGORY = 'category' +export const CATEGORY_SET_PRODUCTS = `${SN_CATEGORY}/SET_PRODUCTS` +export const CATEGORY_ADD_PRODUCTS = `${SN_CATEGORY}/ADD_PRODUCTS` +export const CATEGORY_SET_SEARCH_PRODUCTS_STATS = `${SN_CATEGORY}/SET_SEARCH_PRODUCTS_STATS` +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` diff --git a/core/modules/catalog-next/store/category/mutations.ts b/core/modules/catalog-next/store/category/mutations.ts new file mode 100644 index 000000000..c391af5f4 --- /dev/null +++ b/core/modules/catalog-next/store/category/mutations.ts @@ -0,0 +1,44 @@ +import { isServer } from '@vue-storefront/core/helpers'; +import { nonReactiveState } from './index'; +import Vue from 'vue' +import { MutationTree } from 'vuex' +import * as types from './mutation-types' +import CategoryState from './CategoryState' +import { Category } from '../../types/Category' +import cloneDeep from 'lodash-es/cloneDeep' + +const mutations: MutationTree = { + [types.CATEGORY_SET_PRODUCTS] (state, products = []) { + nonReactiveState.products = cloneDeep(products) + state.products = isServer ? products : products.map(prod => prod.sku) + }, + [types.CATEGORY_ADD_PRODUCTS] (state, products = []) { + nonReactiveState.products.push(...cloneDeep(products)) + state.products.push(...(isServer ? products : products.map(prod => prod.sku))) + }, + [types.CATEGORY_ADD_CATEGORY] (state, category: Category) { + if (category) { + Vue.set(state.categoriesMap, category.id, category) + } + }, + [types.CATEGORY_ADD_CATEGORIES] (state, categories: Category[] = []) { + if (categories.length) { + let newCategoriesEntry = {} + categories.forEach(category => { + newCategoriesEntry[category.id] = category + }) + state.categoriesMap = Object.assign({}, state.categoriesMap, newCategoriesEntry) + } + }, + [types.CATEGORY_ADD_NOT_FOUND_CATEGORY_IDS] (state, categoryIds: string[] = []) { + state.notFoundCategoryIds = [...state.notFoundCategoryIds, ...categoryIds] + }, + [types.CATEGORY_SET_CATEGORY_FILTERS] (state, {category, filters}) { + state.filtersMap[category.id] = filters + }, + [types.CATEGORY_SET_SEARCH_PRODUCTS_STATS] (state, stats = {}) { + state.searchProductsStats = stats + } +} + +export default mutations diff --git a/core/modules/catalog-next/test/unit/_prepareCategoryPathIds.spec.ts b/core/modules/catalog-next/test/unit/_prepareCategoryPathIds.spec.ts new file mode 100644 index 000000000..00fac236a --- /dev/null +++ b/core/modules/catalog-next/test/unit/_prepareCategoryPathIds.spec.ts @@ -0,0 +1,51 @@ +import { _prepareCategoryPathIds } from '@vue-storefront/core/modules/catalog-next/helpers/categoryHelpers'; +import { Category } from '../../types/Category'; + +jest.mock('@vue-storefront/core/modules/breadcrumbs/helpers', () => jest.fn()); + +describe('_prepareCategoryPathIds method', () => { + let parentCategory: Category + + beforeEach(() => { + parentCategory = { + 'path': '1/2', + 'is_active': true, + 'level': 1, + 'product_count': 1181, + 'children_count': '38', + 'parent_id': 1, + 'name': 'All', + 'id': 2, + 'url_key': 'all-2', + 'children_data': [], + 'url_path': 'all-2', + 'slug': 'all-2' + } + }) + + it('should return empty array when no category provided', () => { + const result = _prepareCategoryPathIds(null) + expect(result).toBeDefined() + expect(result.length).toEqual(0) + }); + + it('should return an array from category path', () => { + const result = _prepareCategoryPathIds(parentCategory) + expect(result).toBeDefined() + expect(result).toEqual(['1', '2']) + }); + + it('should return array with single value', () => { + parentCategory.path = '2' + const result = _prepareCategoryPathIds(parentCategory) + expect(result).toBeDefined() + expect(result).toEqual(['2']) + }) + + it('should return array deep connection', () => { + parentCategory.path = '1/2/20/21/252' + const result = _prepareCategoryPathIds(parentCategory) + expect(result).toBeDefined() + expect(result).toEqual(['1', '2', '20', '21', '252']) + }) +}) diff --git a/core/modules/catalog-next/test/unit/changeFilterQuery.spec.ts b/core/modules/catalog-next/test/unit/changeFilterQuery.spec.ts new file mode 100644 index 000000000..d0145e932 --- /dev/null +++ b/core/modules/catalog-next/test/unit/changeFilterQuery.spec.ts @@ -0,0 +1,124 @@ +import { changeFilterQuery } from '@vue-storefront/core/modules/catalog-next/helpers/filterHelpers'; +import FilterVariant from '@vue-storefront/core/modules/catalog-next/types/FilterVariant'; + +describe('changeFilterQuery method', () => { + it('should not change query when no filter variant provided', () => { + const currentQuery = { + color: 1 + }; + const result = changeFilterQuery({ currentQuery }); + expect(result).toEqual(currentQuery); + }); + + it('should not return same query object instance', () => { + const currentQuery = { + color: 1 + }; + const result = changeFilterQuery({ currentQuery }); + expect(result).not.toBe(currentQuery); + }); + + it('should add filter to array', () => { + const currentQuery = {}; + const filterVariant: FilterVariant = { + id: '33', + label: 'Red', + type: 'color' + }; + const result = changeFilterQuery({ currentQuery, filterVariant }); + expect(result).toEqual({ color: ['33'] }); + }); + + it('should remove filter if exist in query', () => { + const currentQuery = { + color: ['23', '33'] + }; + const filterVariant: FilterVariant = { + id: '33', + label: 'Red', + type: 'color' + }; + const result = changeFilterQuery({ currentQuery, filterVariant }); + expect(result).toEqual({ color: ['23'] }); + }); + + it('should add sort filter', () => { + const currentQuery = {}; + const filterVariant: FilterVariant = { + id: 'final_price', + label: 'Price: Low to high', + type: 'sort' + }; + const result = changeFilterQuery({ currentQuery, filterVariant }); + expect(result).toEqual({ sort: 'final_price' }); + }); + + it('should remove sort filter', () => { + const currentQuery = { sort: 'final_price' }; + const filterVariant: FilterVariant = { + id: 'final_price', + label: 'Price: Low to high', + type: 'sort' + }; + const result = changeFilterQuery({ currentQuery, filterVariant }); + expect(result).toEqual({}); + }); + + it('should change sort filter', () => { + const currentQuery = { sort: 'final_price' }; + const filterVariant: FilterVariant = { + id: 'updated_at', + label: 'Latest', + type: 'sort' + }; + const result = changeFilterQuery({ currentQuery, filterVariant }); + expect(result).toEqual({ sort: 'updated_at' }); + }); + + it('should add single filter when there is none', () => { + const currentQuery = { + }; + const filterVariant: FilterVariant = { + id: '50.0-100.0', + type: 'price', + from: '50', + to: '100', + label: '$50 - 100', + single: true + }; + const result = changeFilterQuery({ currentQuery, filterVariant }); + expect(result).toEqual({ price: ['50.0-100.0'] }); + }); + + it('should remove single filter when adding is the same', () => { + const currentQuery = { + price: ['50.0-100.0'] + }; + const filterVariant: FilterVariant = { + id: '50.0-100.0', + type: 'price', + from: '50', + to: '100', + label: '$50 - 100', + single: true + }; + const result = changeFilterQuery({ currentQuery, filterVariant }); + expect(result).toEqual({}); + }); + + it('should change single filter when adding another single', () => { + const currentQuery = { + price: ['100.0-150.0'] + }; + const filterVariant: FilterVariant = { + id: '50.0-100.0', + type: 'price', + from: '50', + to: '100', + label: '$50 - 100', + single: true + }; + const result = changeFilterQuery({ currentQuery, filterVariant }); + expect(result).toEqual({ price: ['50.0-100.0'] }); + }); +}); diff --git a/core/modules/catalog-next/test/unit/getFiltersFromQuery.spec.ts b/core/modules/catalog-next/test/unit/getFiltersFromQuery.spec.ts new file mode 100644 index 000000000..610f9c0e9 --- /dev/null +++ b/core/modules/catalog-next/test/unit/getFiltersFromQuery.spec.ts @@ -0,0 +1,152 @@ +import { getFiltersFromQuery } from '../../helpers/filterHelpers'; + +describe('getFiltersFromQuery method', () => { + let availableFilters + beforeEach(() => { + availableFilters = { + 'color': [ + { + 'id': '49', + 'label': 'Black', + 'type': 'color' + }, + { + 'id': '50', + 'label': 'Blue', + 'type': 'color' + } + ], + 'size': [ + { + 'id': '172', + 'label': '28', + 'type': 'size' + }, + { + 'id': '170', + 'label': 'L', + 'type': 'size' + }, + { + 'id': '169', + 'label': 'M', + 'type': 'size' + } + ], + 'price': [ + { + 'id': '0.0-50.0', + 'type': 'price', + 'from': 0, + 'to': 50, + 'label': '< $50' + }, + { + 'id': '50.0-100.0', + 'type': 'price', + 'from': 50, + 'to': 100, + 'label': '$50 - 100' + }, + { + 'id': '150.0-*', + 'type': 'price', + 'from': 150, + 'label': '> $150' + } + ], + 'erin_recommends': [ + { + 'id': '0', + 'label': 'No', + 'type': 'erin_recommends' + }, + { + 'id': '1', + 'label': 'Yes', + 'type': 'erin_recommends' + } + ], + 'sort': [ + { + 'label': 'Latest', + 'id': 'updated_at', + 'type': 'sort' + }, + { + 'label': 'Price: Low to high', + 'id': 'final_price', + 'type': 'sort' + }, + { + 'label': 'Price: High to low', + 'id': 'final_price:desc', + 'type': 'sort' + } + ] + } + }); + + it('should return color filter', () => { + const filtersQuery = { + color: '49' + } + const result = getFiltersFromQuery({availableFilters, filtersQuery}) + expect(result).toEqual({ + filters: { + color: { + 'id': '49', + 'label': 'Black', + 'type': 'color', + 'attribute_code': 'color' + } + } + }) + }); + + it('should return color filters', () => { + const filtersQuery = { + color: ['49', '50'] + } + const result = getFiltersFromQuery({availableFilters, filtersQuery}) + expect(result).toEqual({ + filters: { + color: [ + { + 'id': '49', + 'label': 'Black', + 'type': 'color', + 'attribute_code': 'color' + }, + { + 'id': '50', + 'label': 'Blue', + 'type': 'color', + 'attribute_code': 'color' + } + ] + } + }) + }); + + it('should not return not existing filter', () => { + const filtersQuery = { + color: '111' + } + const result = getFiltersFromQuery({availableFilters, filtersQuery}) + expect(result).toEqual({ + filters: {} + }) + }); + + it('should return size filter', () => { + const filtersQuery = { + sort: 'updated_at' + } + const result = getFiltersFromQuery({availableFilters, filtersQuery}) + expect(result).toEqual({ + filters: {}, + sort: 'updated_at' + }) + }); +}); diff --git a/core/modules/catalog-next/test/unit/prefetchStockItems.spec.ts b/core/modules/catalog-next/test/unit/prefetchStockItems.spec.ts new file mode 100644 index 000000000..60662a9ff --- /dev/null +++ b/core/modules/catalog-next/test/unit/prefetchStockItems.spec.ts @@ -0,0 +1,71 @@ +import {prefetchStockItems} from '../../helpers/cacheProductsHelper'; +import config from 'config'; + +describe('prefetchStockItems method', () => { + describe('default configurableChildrenStockPrefetchStaticPrefetchCount', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mock('config', () => ({})); + }) + + it('returns an empty array when no items are provided', () => { + const cachedProductsResponse = { + items: [] + } + const result = prefetchStockItems(cachedProductsResponse) + expect(result).toEqual([]); + }) + + it('returns the skus of the children of a configurable', () => { + const cachedProductsResponse = { + items: [ + {sku: 'foo'}, + { + sku: 'bar', + type_id: 'configurable', + configurable_children: [ + {sku: 'bar.foo'}, + {sku: 'bar.bar'}, + {sku: 'bar.baz'} + ] + }, + {sku: 'baz'} + ] + } + const result = prefetchStockItems(cachedProductsResponse) + expect(result).toEqual(['foo', 'bar', 'bar.foo', 'bar.bar', 'bar.baz', 'baz']); + }) + + it('returns the same skus of the provided simple products', () => { + const cachedProductsResponse = { + items: [ + {sku: 'foo'}, + {sku: 'bar'}, + {sku: 'baz'} + ] + } + const result = prefetchStockItems(cachedProductsResponse) + expect(result).toEqual(['foo', 'bar', 'baz']); + }) + + it('ignores the pre-cached skus of children of a configurable', () => { + const cachedProductsResponse = { + items: [ + {sku: 'foo'}, + { + sku: 'bar', + type_id: 'configurable', + configurable_children: [ + {sku: 'bar.foo', id: 1337}, + {sku: 'bar.bar'}, + {sku: 'bar.baz', id: 4711} + ] + }, + {sku: 'baz'} + ] + } + const result = prefetchStockItems(cachedProductsResponse, {1337: {}, 4711: {}}) + expect(result).toEqual(['foo', 'bar', 'bar.bar', 'baz']); + }) + }) +}) diff --git a/core/modules/catalog-next/types/Category.d.ts b/core/modules/catalog-next/types/Category.d.ts new file mode 100644 index 000000000..b7d22eb49 --- /dev/null +++ b/core/modules/catalog-next/types/Category.d.ts @@ -0,0 +1,22 @@ +export interface ChildrenData { + id: number | string, + children_data?: ChildrenData[], + name?: string, + slug?: string, + url_key?: string +} + +export interface Category { + path: string, + is_active: boolean, + level: number, + product_count: number, + children_count: string, + parent_id: number | string, + name: string, + id: number | string, + url_path: string, + url_key: string, + children_data: ChildrenData[], + slug: string +} diff --git a/core/modules/catalog-next/types/FilterVariant.ts b/core/modules/catalog-next/types/FilterVariant.ts new file mode 100644 index 000000000..8f8f2d8d7 --- /dev/null +++ b/core/modules/catalog-next/types/FilterVariant.ts @@ -0,0 +1,8 @@ +export default interface FilterVariant { + id: string, + label: string, + type: string, + from?: string, + to?: string, + single?: boolean +} diff --git a/core/modules/catalog/components/CategorySort.ts b/core/modules/catalog/components/CategorySort.ts index b014fc1dd..10214890e 100644 --- a/core/modules/catalog/components/CategorySort.ts +++ b/core/modules/catalog/components/CategorySort.ts @@ -20,13 +20,25 @@ export const CategorySort = { methods: { // emit to category, todo: move all logic inside sort () { - this.$bus.$emit('list-change-sort', { attribute: this.sortby }) + this.$emit('change', this.sortby) + // this.$bus.$emit('list-change-sort', { attribute: this.sortby }) } }, computed: { ...mapGetters('category', ['getCurrentCategoryProductQuery']), sortingOptions () { return config.products.sortByAttributes + }, + sortingVariants () { + let variants = [] + Object.keys(this.sortingOptions).map(label => { + variants.push({ + label: label, + id: this.sortingOptions[label], + type: 'sort' + }) + }) + return variants } } } diff --git a/core/modules/catalog/components/ProductAttribute.ts b/core/modules/catalog/components/ProductAttribute.ts index 355c0e14c..c0a855ecd 100644 --- a/core/modules/catalog/components/ProductAttribute.ts +++ b/core/modules/catalog/components/ProductAttribute.ts @@ -16,7 +16,7 @@ export const ProductAttribute = { }, computed: { label () { - return (this.attribute && this.attribute.default_frontend_label) ? this.attribute.default_frontend_label : '' + return (this.attribute && this.attribute.frontend_label) ? this.attribute.frontend_label : ((this.attribute && this.attribute.default_frontend_label) ? this.attribute.default_frontend_label : '') }, value () { let parsedValues = this.product[this.attribute.attribute_code] diff --git a/core/modules/catalog/components/ProductBundleOption.ts b/core/modules/catalog/components/ProductBundleOption.ts index bd770652b..d902cbead 100644 --- a/core/modules/catalog/components/ProductBundleOption.ts +++ b/core/modules/catalog/components/ProductBundleOption.ts @@ -29,7 +29,11 @@ export const ProductBundleOption = { return `bundleOptionQty_${this.option.option_id}` }, value () { - return this.option.product_links.find(product => product.id === this.productOptionId) + const { product_links } = this.option + if (Array.isArray(product_links)) { + return product_links.find(product => product.id === this.productOptionId) + } + return product_links }, errorMessage () { return this.errorMessages ? this.errorMessages[this.quantityName] : '' @@ -56,14 +60,19 @@ export const ProductBundleOption = { }, methods: { setDefaultValues () { - if (this.option.product_links) { - const defaultOption = this.option.product_links.find(pl => { return pl.is_default }) - this.productOptionId = defaultOption ? defaultOption.id : this.option.product_links[0].id + const { product_links } = this.option + + if (product_links) { + const defaultOption = Array.isArray(product_links) + ? product_links.find(pl => pl.is_default) + : product_links + + this.productOptionId = defaultOption ? defaultOption.id : product_links[0].id this.quantity = defaultOption ? defaultOption.qty : 1 } }, bundleOptionChanged () { - this.$emit('optionChanged', { + this.$emit('option-changed', { option: this.option, fieldName: this.productBundleOption, qty: this.quantity, diff --git a/core/modules/catalog/components/ProductBundleOptions.ts b/core/modules/catalog/components/ProductBundleOptions.ts index 4df0de027..84f5d7182 100644 --- a/core/modules/catalog/components/ProductBundleOptions.ts +++ b/core/modules/catalog/components/ProductBundleOptions.ts @@ -43,7 +43,7 @@ export const ProductBundleOptions = { }, methods: { ...mapMutations('product', { - setBundleOptionValue: types.CATALOG_UPD_BUNDLE_OPTION // map `this.add()` to `this.$store.commit('increment')` + setBundleOptionValue: types.PRODUCT_SET_BUNDLE_OPTION // map `this.add()` to `this.$store.commit('increment')` }), setupValidationRules () { rootStore.dispatch('product/addCustomOptionValidator', { @@ -63,7 +63,7 @@ export const ProductBundleOptions = { }, optionChanged ({fieldName, option, qty, value}) { if (!fieldName) return - this.setBundleOptionValue({ optionId: option.option_id, optionQty: parseInt(qty), optionSelections: [value.id] }) + this.setBundleOptionValue({ optionId: option.option_id, optionQty: parseInt(qty), optionSelections: [parseInt(value.id)] }) this.$store.dispatch('product/setBundleOptions', { product: this.product, bundleOptions: this.$store.state.product.current_bundle_options }) // TODO: move it to "AddToCart" this.selectedOptions[fieldName] = {qty, value} const valueId = value ? value.id : null diff --git a/core/modules/catalog/components/ProductCustomOption.ts b/core/modules/catalog/components/ProductCustomOption.ts index 898fd6a93..e3752ecb1 100644 --- a/core/modules/catalog/components/ProductCustomOption.ts +++ b/core/modules/catalog/components/ProductCustomOption.ts @@ -4,7 +4,7 @@ export const ProductCustomOption = { label: { type: String, required: false, - default: () => false + default: '' }, id: { type: null, @@ -38,12 +38,7 @@ export const ProductCustomOption = { methods: { filterChanged (filterOption) { if (filterOption.attribute_code === this.code) { - if (filterOption.id === this.id) { - this.active = !this.active - } else { - this.active = false - } - // filterOption.id === this.id ? this.active = true : this.active = false + this.active = filterOption.id === this.id ? !this.active : false } }, filterReset (filterOption) { diff --git a/core/modules/catalog/components/ProductCustomOptions.ts b/core/modules/catalog/components/ProductCustomOptions.ts index 8a0d8e1aa..821eecde0 100644 --- a/core/modules/catalog/components/ProductCustomOptions.ts +++ b/core/modules/catalog/components/ProductCustomOptions.ts @@ -1,21 +1,10 @@ +import { customOptionFieldName, selectedCustomOptionValue, defaultCustomOptionValue } from '@vue-storefront/core/modules/catalog/helpers/customOption'; import { mapMutations } from 'vuex' import * as types from '../store/product/mutation-types' import rootStore from '@vue-storefront/core/store' import i18n from '@vue-storefront/i18n' import { Logger } from '@vue-storefront/core/lib/logger' -function _defaultOptionValue (co) { - switch (co.type) { - case 'radio': return co.values && co.values.length ? co.values[0].option_type_id : 0 - case 'checkbox': return false - default: return '' - } -} - -function _fieldName (co) { - return 'customOption_' + co.option_id -} - export const ProductCustomOptions = { name: 'ProductCustomOptions', props: { @@ -26,50 +15,61 @@ export const ProductCustomOptions = { }, data () { return { - inputValues: { - }, - selectedOptions: { - }, + inputValues: {}, validation: { rules: {}, results: {} } } }, + computed: { + selectedOptions () { + const customOptions = this.product.custom_options + if (!customOptions) { + return {} + } + + return customOptions.reduce((selectedOptions, option) => { + const fieldName = customOptionFieldName(option) + selectedOptions[fieldName] = selectedCustomOptionValue(option.type, option.values, this.inputValues[fieldName]) + return selectedOptions + }, {}) + } + }, created () { rootStore.dispatch('product/addCustomOptionValidator', { validationRule: 'required', // You may add your own custom fields validators elsewhere in the theme validatorFunction: (value) => { - return { error: (value === null || value === '') || (value === false) || (value === 0), message: i18n.t('Field is required.') } + const error = Array.isArray(value) ? !value.length : !value + const message = i18n.t('Field is required.') + return { error, message } } }) this.setupInputFields() }, methods: { ...mapMutations('product', { - setCustomOptionValue: types.CATALOG_UPD_CUSTOM_OPTION // map `this.add()` to `this.$store.commit('increment')` + setCustomOptionValue: types.PRODUCT_SET_CUSTOM_OPTION // map `this.add()` to `this.$store.commit('increment')` }), setupInputFields () { - for (let co of this.product.custom_options) { - const fieldName = _fieldName(co) - this['inputValues'][fieldName] = _defaultOptionValue(co) - if (co.is_require) { // validation rules are very basic + for (const customOption of this.product.custom_options) { + const fieldName = customOptionFieldName(customOption) + this['inputValues'][fieldName] = defaultCustomOptionValue(customOption) + if (customOption.is_require) { // validation rules are very basic this.validation.rules[fieldName] = 'required' // TODO: add custom validators for the custom options } - this.optionChanged(co, co.values && co.values.length > 0 ? co.values[0] : null) + this.optionChanged(customOption) } }, - optionChanged (option, opval = null) { - const fieldName = _fieldName(option) - const value = opval === null ? this.inputValues[fieldName] : opval.option_type_id + optionChanged (option) { + const fieldName = customOptionFieldName(option) this.validateField(option) - this.setCustomOptionValue({ optionId: option.option_id, optionValue: value }) + this.setCustomOptionValue({ optionId: option.option_id, optionValue: this.selectedOptions[fieldName] }) this.$store.dispatch('product/setCustomOptions', { product: this.product, customOptions: this.$store.state.product.current_custom_options }) // TODO: move it to "AddToCart" - this.selectedOptions[fieldName] = (opval === null ? value : opval) this.$bus.$emit('product-after-customoptions', { product: this.product, option: option, optionValues: this.selectedOptions }) }, validateField (option) { - const fieldName = _fieldName(option) + const fieldName = customOptionFieldName(option) const validationRule = this.validation.rules[fieldName] this.product.errors.custom_options = null if (validationRule) { diff --git a/core/modules/catalog/components/ProductOption.ts b/core/modules/catalog/components/ProductOption.ts new file mode 100644 index 000000000..4aa4c4452 --- /dev/null +++ b/core/modules/catalog/components/ProductOption.ts @@ -0,0 +1,20 @@ +import { isOptionAvailableAsync } from '@vue-storefront/core/modules/catalog/helpers/index' +import { getAvailableFiltersByProduct, getSelectedFiltersByProduct } from '@vue-storefront/core/modules/catalog/helpers/filters' + +export const ProductOption = { + computed: { + getAvailableFilters () { + return getAvailableFiltersByProduct(this.product) + }, + getSelectedFilters () { + return getSelectedFiltersByProduct(this.product, this.configuration) + } + }, + methods: { + isOptionAvailable (option) { // check if the option is available + let currentConfig = Object.assign({}, this.configuration) + currentConfig[option.type] = option + return isOptionAvailableAsync(this.$store, { product: this.product, configuration: currentConfig }) + } + } +} diff --git a/core/modules/catalog/components/ProductTile.ts b/core/modules/catalog/components/ProductTile.ts index 12d6d801c..8dcdad035 100644 --- a/core/modules/catalog/components/ProductTile.ts +++ b/core/modules/catalog/components/ProductTile.ts @@ -34,10 +34,10 @@ export const ProductTile = { } }, isOnSale () { - return this.product.sale === '1' ? 'sale' : '' + return [true, '1'].includes(this.product.sale) ? 'sale' : '' }, isNew () { - return this.product.new === '1' ? 'new' : '' + return [true, '1'].includes(this.product.new) ? 'new' : '' } } } diff --git a/core/modules/catalog/components/ProductVideo.ts b/core/modules/catalog/components/ProductVideo.ts index e165f23f8..9c66809bf 100644 --- a/core/modules/catalog/components/ProductVideo.ts +++ b/core/modules/catalog/components/ProductVideo.ts @@ -5,7 +5,7 @@ export const ProductVideo = { type: String, required: true }, - video_id: { + id: { type: String, required: true }, @@ -38,9 +38,9 @@ export const ProductVideo = { embedUrl () { switch (this.type) { case 'youtube': - return `https://www.youtube.com/embed/${this.video_id}?autoplay=1` + return `https://www.youtube.com/embed/${this.id}?autoplay=1` case 'vimeo': - return `https://player.vimeo.com/video/${this.video_id}?autoplay=1` + return `https://player.vimeo.com/video/${this.id}?autoplay=1` default: } } diff --git a/core/modules/catalog/components/Search.ts b/core/modules/catalog/components/Search.ts index fb3a1ae9d..7758b807d 100644 --- a/core/modules/catalog/components/Search.ts +++ b/core/modules/catalog/components/Search.ts @@ -15,11 +15,12 @@ export const Search = { start: 0, placeholder: i18n.t('Type what you are looking for...'), emptyResults: false, - readMore: true + readMore: true, + componentLoaded: false } }, mounted () { - this.search = localStorage.getItem(`shop/user/searchQuery`); + this.search = localStorage.getItem(`shop/user/searchQuery`) || '' if (this.search) { this.makeSearch(); @@ -47,7 +48,7 @@ export const Search = { let startValue = 0; this.start = startValue this.readMore = true - this.$store.dispatch('product/list', { query, start: this.start, size: this.size, updateState: false }).then(resp => { + this.$store.dispatch('product/list', { query, start: this.start, configuration: {}, size: this.size, updateState: false }).then(resp => { this.products = resp.items this.start = startValue + this.size this.emptyResults = resp.items.length < 1 diff --git a/core/modules/catalog/events.ts b/core/modules/catalog/events.ts new file mode 100644 index 000000000..8eb005a11 --- /dev/null +++ b/core/modules/catalog/events.ts @@ -0,0 +1,112 @@ +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' +import { PRODUCT_SET_CURRENT_CONFIGURATION, PRODUCT_SET_CURRENT } from './store/product/mutation-types' +import omit from 'lodash-es/omit' +import config from 'config' + +// Listeners moved from Product.js + +const prefixMutation = (mutationKey) => `product/${mutationKey}` + +export const productAfterPriceupdate = async (product, store) => { + if (store.getters['product/getCurrentProduct'] && product.sku === store.getters['product/getCurrentProduct'].sku) { + // join selected variant object to the store + await store.dispatch('product/setCurrent', omit(product, ['name'])) + } +} + +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'], + configuration: changedConfig, + selectDefaultVariant: true, + fallbackToDefaultWhenNoAvailable: false, + setProductErorrs: true + }, { root: true }) + if (config.products.setFirstVarianAsDefaultInURL) { + router.push({params: { 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 }) + } + } +} + +export const productAfterCustomoptions = async (payload, store) => { + let priceDelta = 0 + let priceDeltaInclTax = 0 + const optionValues: any[] = Object.values(payload.optionValues) + optionValues.forEach(optionValue => { + if (optionValue && parseInt(optionValue.option_type_id) > 0) { + if (optionValue.price_type === 'fixed' && optionValue.price !== 0) { + priceDelta += optionValue.price + 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) + } + } + }) + + store.commit(prefixMutation(PRODUCT_SET_CURRENT), Object.assign( + {}, + store.getters['product/getCurrentProduct'], + { + price: store.getters['product/getOriginalProduct'].price + priceDelta, + price_incl_tax: store.getters['product/getOriginalProduct'].price_incl_tax + priceDeltaInclTax + } + ), { root: true }) +} + +export const productAfterBundleoptions = async (payload, store) => { + let priceDelta = 0 + let priceDeltaInclTax = 0 + const optionValues: any[] = Object.values(payload.optionValues) + optionValues.forEach(optionValue => { + if (optionValue && optionValue.value && optionValue.product && parseInt(optionValue.qty) >= 0) { + priceDelta += optionValue.value.product.price * parseInt(optionValue.qty) + priceDeltaInclTax += optionValue.value.product.price_incl_tax * parseInt(optionValue.qty) + } + }) + if (priceDelta > 0) { + store.commit(prefixMutation(PRODUCT_SET_CURRENT), Object.assign( + {}, + store.getters['product/getCurrentProduct'], + { + price: priceDelta, + price_incl_tax: priceDeltaInclTax + } + ), { root: true }) + } +} + +export const onUserPricesRefreshed = async (store, router) => { + if (router.currentRoute.params.parentSku) { + await store.dispatch('product/reset', {}, { root: true }) + await store.dispatch('product/single', { + options: { + sku: router.currentRoute.params.parentSku, + childSku: router && router.currentRoute.params && router.currentRoute.params.childSku ? router.currentRoute.params.childSku : null + }, + skipCache: true + }, { root: true }) + } +} diff --git a/core/modules/catalog/helpers/areAttributesAlreadyLoaded.ts b/core/modules/catalog/helpers/areAttributesAlreadyLoaded.ts new file mode 100644 index 000000000..967578df4 --- /dev/null +++ b/core/modules/catalog/helpers/areAttributesAlreadyLoaded.ts @@ -0,0 +1,38 @@ +import config from 'config' + +const areAttributesAlreadyLoaded = ({ + filterValues, + filterField, + blacklist, + idsList, + codesList +}: { + filterValues: string[], + filterField: string, + blacklist: string[], + idsList: any, + codesList: any +}): boolean => { + return filterValues.filter(fv => { + if (config.entities.product.standardSystemFields.indexOf(fv) >= 0) { + return false + } + + if (fv.indexOf('.') >= 0) { + return false + } + + if (blacklist !== null && blacklist.includes(fv)) { + return false + } + + if (filterField === 'attribute_id') { + return (typeof idsList[fv] === 'undefined' || idsList[fv] === null) + } + if (filterField === 'attribute_code') { + return (typeof codesList[fv] === 'undefined' || codesList[fv] === null) + } + }).length === 0 +} + +export default areAttributesAlreadyLoaded diff --git a/core/modules/catalog/helpers/createAttributesListQuery.ts b/core/modules/catalog/helpers/createAttributesListQuery.ts new file mode 100644 index 000000000..a8d9c930f --- /dev/null +++ b/core/modules/catalog/helpers/createAttributesListQuery.ts @@ -0,0 +1,29 @@ +import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' + +const createAttributesListQuery = ({ + filterValues, + filterField, + onlyDefinedByUser, + onlyVisible +}: { + filterValues: string[], + filterField: string, + onlyDefinedByUser: boolean, + onlyVisible: boolean +}): SearchQuery => { + let searchQuery = new SearchQuery() + + if (filterValues) { + searchQuery = searchQuery.applyFilter({key: filterField, value: {'in': filterValues}}) + } + if (onlyDefinedByUser) { + searchQuery = searchQuery.applyFilter({key: 'is_user_defined', value: {'in': [true]}}) + } + if (onlyVisible) { + searchQuery = searchQuery.applyFilter({key: 'is_visible', value: {'in': [true]}}) + } + + return searchQuery +} + +export default createAttributesListQuery diff --git a/core/modules/catalog/helpers/createCategoryListQuery.ts b/core/modules/catalog/helpers/createCategoryListQuery.ts new file mode 100644 index 000000000..d06ac7eae --- /dev/null +++ b/core/modules/catalog/helpers/createCategoryListQuery.ts @@ -0,0 +1,42 @@ +import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' +import { isServer } from '@vue-storefront/core/helpers' +import config from 'config' + +const createCategoryListQuery = ({ parent, level, key, value, onlyActive, onlyNotEmpty }) => { + let isCustomizedQuery = false + let searchQuery = new SearchQuery() + + if (parent) { + searchQuery = searchQuery.applyFilter({key: 'parent_id', value: { 'eq': typeof parent === 'object' ? parent.id : parent }}) + isCustomizedQuery = true + } + + if (level !== null) { + searchQuery = searchQuery.applyFilter({key: 'level', value: {'eq': level}}) + if (level !== config.entities.category.categoriesDynamicPrefetchLevel && !isServer) { + isCustomizedQuery = true + } + } + + if (key !== null) { + if (Array.isArray(value)) { + searchQuery = searchQuery.applyFilter({key: key, value: { 'in': value }}) + } else { + searchQuery = searchQuery.applyFilter({key: key, value: { 'eq': value }}) + } + isCustomizedQuery = true + } + + if (onlyActive === true) { + searchQuery = searchQuery.applyFilter({key: 'is_active', value: { 'eq': true }}) + } + + if (onlyNotEmpty === true) { + searchQuery = searchQuery.applyFilter({key: 'product_count', value: { 'gt': 0 }}) + isCustomizedQuery = true + } + + return { searchQuery, isCustomizedQuery } +} + +export default createCategoryListQuery diff --git a/core/modules/catalog/helpers/customOption.ts b/core/modules/catalog/helpers/customOption.ts new file mode 100644 index 000000000..04cade1a9 --- /dev/null +++ b/core/modules/catalog/helpers/customOption.ts @@ -0,0 +1,45 @@ +import { CustomOption, OptionValue, InputValue } from './../types/CustomOption'; + +export const defaultCustomOptionValue = (customOption: CustomOption): InputValue => { + switch (customOption.type) { + case 'radio': { + return customOption.values && customOption.values.length ? customOption.values[0].option_type_id : 0 + } + case 'checkbox': { + return [] + } + default: { + return '' + } + } +} + +export const customOptionFieldName = (customOption: CustomOption): string => { + return 'customOption_' + customOption.option_id +} + +export const selectedCustomOptionValue = (optionType: string, optionValues: OptionValue[] = [], inputValue: InputValue): string => { + switch (optionType) { + case 'field': { + return inputValue as string + } + case 'radio': + case 'select': + case 'drop_down': { + const selectedValue = optionValues.find((value) => value.option_type_id === inputValue as number) + const optionTypeId = selectedValue && selectedValue.option_type_id + + return optionTypeId ? String(optionTypeId) : null + } + case 'checkbox': { + const checkboxOptionValues = inputValue as number[] || [] + + return optionValues.filter((value) => checkboxOptionValues.includes(value.option_type_id)) + .map((value) => value.option_type_id) + .join(',') || null + } + default: { + return null + } + } +} diff --git a/core/modules/catalog/helpers/filters.ts b/core/modules/catalog/helpers/filters.ts new file mode 100644 index 000000000..fbabfd45c --- /dev/null +++ b/core/modules/catalog/helpers/filters.ts @@ -0,0 +1,37 @@ +import Product from '@vue-storefront/core/modules/catalog/types/Product' +import { ProductConfiguration } from '@vue-storefront/core/modules/catalog/types/ProductConfiguration' + +const getAvailableFiltersByProduct = (product: Product) => { + let filtersMap = {} + if (product && product.configurable_options) { + product.configurable_options.forEach(configurableOption => { + const type = configurableOption.attribute_code + const filterVariants = configurableOption.values.map(({value_index, label}) => { + return {id: value_index, label, type} + }) + filtersMap[type] = filterVariants + }) + } + return filtersMap +} + +const getSelectedFiltersByProduct = (product: Product, configuration: ProductConfiguration) => { + if (!configuration) { + return null + } + + let selectedFilters = {} + if (configuration && product) { + Object.keys(configuration).map(filterType => { + const filter = configuration[filterType] + selectedFilters[filterType] = { + id: filter.id, + label: filter.label, + type: filterType + } + }) + } + return selectedFilters +} + +export { getAvailableFiltersByProduct, getSelectedFiltersByProduct } diff --git a/core/modules/catalog/helpers/index.ts b/core/modules/catalog/helpers/index.ts index bc9fcf638..7c39e4268 100644 --- a/core/modules/catalog/helpers/index.ts +++ b/core/modules/catalog/helpers/index.ts @@ -1,20 +1,17 @@ -import Vue from 'vue' +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' import rootStore from '@vue-storefront/core/store' -import { calculateProductTax } from '../helpers/tax' import flattenDeep from 'lodash-es/flattenDeep' import omit from 'lodash-es/omit' import remove from 'lodash-es/remove' -import groupBy from 'lodash-es/groupBy' import toString from 'lodash-es/toString' import union from 'lodash-es/union' // TODO: Remove this dependency import { optionLabel } from './optionLabel' 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' -import config from 'config'; +import config from 'config' function _filterRootProductByStockitem (context, stockItem, product, errorCallback) { if (stockItem) { @@ -22,7 +19,7 @@ function _filterRootProductByStockitem (context, stockItem, product, errorCallba if (stockItem.is_in_stock === false) { product.errors.variants = i18n.t('No available product variants') context.state.current.errors = product.errors - Vue.prototype.$bus.$emit('product-after-removevariant', { product: product }) + EventBus.$emit('product-after-removevariant', { product: product }) if (config.products.listOutOfStockProducts === false) { errorCallback(new Error('Product query returned an empty result')) } @@ -40,6 +37,7 @@ 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) { @@ -57,10 +55,19 @@ export function findConfigurableChildAsync ({ product, configuration = null, sel if (configuration.sku) { return configurableChild.sku === configuration.sku // by sku or first one } else { - return Object.keys(omit(configuration, ['price'])).every((configProperty) => { - if (!configuration[configProperty] || typeof configuration[configProperty].id === 'undefined') return true // skip empty - return toString(configurableChild[configProperty]) === toString(configuration[configProperty].id) - }) + 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 @@ -96,7 +103,7 @@ function _filterChildrenByStockitem (context, stockItems, product, diffLog) { const variant = isOptionAvailableAsync(context, { product: product, configuration: config }) if (!variant) { Logger.log('No variant for' + opt, 'helper')() - Vue.prototype.$bus.$emit('product-after-removevariant', { product: product }) + EventBus.$emit('product-after-removevariant', { product: product }) removedOptions++ return false } else { @@ -113,7 +120,7 @@ function _filterChildrenByStockitem (context, stockItems, product, diffLog) { if (totalOptions === 0) { product.errors.variants = i18n.t('No available product variants') context.state.current.errors = product.errors - Vue.prototype.$bus.$emit('product-after-removevariant', { product: product }) + EventBus.$emit('product-after-removevariant', { product: product }) } } } @@ -177,23 +184,33 @@ export function filterOutUnavailableVariants (context, product) { 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.priceInclTax = backProduct.price_info.final_price - product.originalPriceInclTax = backProduct.price_info.regular_price - product.specialPriceInclTax = backProduct.price_info.special_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.originalPrice = backProduct.price_info.extension_attributes.tax_adjustments.regular_price + product.original_price = backProduct.price_info.extension_attributes.tax_adjustments.regular_price - product.priceTax = product.priceInclTax - product.price - product.specialPriceTax = product.specialPriceInclTax - product.special_price - product.originalPriceTax = product.originalPriceInclTax - product.originalPrice + 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.priceInclTax >= product.originalPriceInclTax) { - product.specialPriceInclTax = 0 + if (product.price_incl_tax >= product.original_price_incl_tax) { + product.special_price_incl_tax = 0 product.special_price = 0 } - Vue.prototype.$bus.$emit('product-after-priceupdate', product) + + /** 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 } @@ -206,31 +223,51 @@ export function doPlatformPricesSync (products) { 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.priceInclTax = null - product.originalPriceInclTax = null - product.specialPriceInclTax = null + 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.originalPrice = null - - product.priceTax = null - product.specialPriceTax = null - product.originalPriceTax = 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.priceInclTax = null - sc.originalPriceInclTax = null - sc.specialPriceInclTax = null + 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.originalPrice = null - - sc.priceTax = null - sc.specialPriceTax = null - sc.originalPriceTax = 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 */ } } } @@ -285,30 +322,6 @@ export function doPlatformPricesSync (products) { } }) } -// TODO: should be moved to tax -/** - * Calculate taxes for specific product collection - */ -export function calculateTaxes (products, store) { - return new Promise((resolve, reject) => { - if (config.tax.calculateServerSide) { - Logger.debug('Taxes calculated server side, skipping')() - doPlatformPricesSync(products).then((products) => { - resolve(products) - }) - } else { - const storeView = currentStoreView() - store.dispatch('tax/list', { query: '' }, { root: true }).then((tcs) => { // TODO: move it to the server side for one requests OR cache in indexedDb - for (let product of products) { - product = calculateProductTax(product, tcs.items, storeView.tax.defaultCountry, storeView.tax.defaultRegion, storeView.tax.sourcePriceIncludesTax) - } - doPlatformPricesSync(products).then((products) => { - resolve(products) - }) - }) // TODO: run Magento2 prices request here if configured so in the config - } - }) -} function _prepareProductOption (product) { let product_option = { @@ -346,13 +359,13 @@ export function setConfigurableProductOptionsAsync (context, { product, configur existingOption = { option_id: option.attribute_id, option_value: configOption.id, - label: i18n.t(configOption.attribute_code), + label: option.label || i18n.t(configOption.attribute_code), value: configOption.label } configurable_item_options.push(existingOption) } existingOption.option_value = configOption.id - existingOption.label = i18n.t(configOption.attribute_code) + existingOption.label = option.label || i18n.t(configOption.attribute_code) existingOption.value = configOption.label } } @@ -422,10 +435,12 @@ export function populateProductConfigurationAsync (context, { product, selectedV value: selectedVariant[attribute_code] } } - 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 + 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 = { @@ -448,7 +463,7 @@ export function populateProductConfigurationAsync (context, { product, selectedV 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.productCurrent + if (product === null) product = context.getters.getCurrentProduct const hasConfigurableChildren = (product.configurable_children && product.configurable_children.length > 0) if (hasConfigurableChildren) { @@ -478,7 +493,7 @@ export function configureProductAsync (context, { product, configuration, select } if (selectedVariant) { - if (!desiredProductFound) { // update the configuration + 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 } @@ -500,11 +515,11 @@ export function configureProductAsync (context, { product, configuration, select 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 + // use chosen variant for the current product if (selectDefaultVariant) { context.dispatch('setCurrent', selectedVariant) } - Vue.prototype.$bus.$emit('product-after-configure', { product: product, configuration: configuration, selectedVariant: 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') @@ -568,7 +583,6 @@ export function attributeImages (product) { } return attributeImages } - /** * Get configurable_children images from product if any * otherwise get attribute images diff --git a/core/modules/catalog/helpers/prefetchCachedAttributes.ts b/core/modules/catalog/helpers/prefetchCachedAttributes.ts new file mode 100644 index 000000000..174eb73ac --- /dev/null +++ b/core/modules/catalog/helpers/prefetchCachedAttributes.ts @@ -0,0 +1,15 @@ +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' +import { entityKeyName } from '@vue-storefront/core/lib/store/entities' +import config from 'config' + +async function prefetchCachedAttributes (filterField, filterValues) { + if (!config.attributes || !config.attributes.disablePersistentAttributesCache) { + const attrCollection = StorageManager.get('attributes') + const cachedAttributes = filterValues.map( + async filterValue => attrCollection.getItem(entityKeyName(filterField, filterValue.toLowerCase())) + ) + return Promise.all(cachedAttributes) + } +} + +export { prefetchCachedAttributes } diff --git a/core/modules/catalog/helpers/reduceAttributesLists.ts b/core/modules/catalog/helpers/reduceAttributesLists.ts new file mode 100644 index 000000000..3662224e6 --- /dev/null +++ b/core/modules/catalog/helpers/reduceAttributesLists.ts @@ -0,0 +1,26 @@ +import Attribute from '@vue-storefront/core/modules/catalog/types/Attribute' + +const reduceAttributes = (prev, curr) => { + if (curr) { + prev.attrHashByCode[curr.attribute_code] = curr + prev.attrHashById[curr.attribute_id] = curr + } + + return prev +} + +const reduceAttributesLists = ({ + codesList, + idsList, + attributes +}: { + codesList: any, + idsList: any, + attributes: Attribute[] +}) => { + return attributes.reduce( + reduceAttributes, { attrHashByCode: codesList, attrHashById: idsList } + ) +} + +export default reduceAttributesLists diff --git a/core/modules/catalog/helpers/search.ts b/core/modules/catalog/helpers/search.ts new file mode 100644 index 000000000..c35d70c8b --- /dev/null +++ b/core/modules/catalog/helpers/search.ts @@ -0,0 +1,95 @@ +import Vue from 'vue'; +import { Logger } from '@vue-storefront/core/lib/logger'; +import config from 'config'; +import { StorageManager } from '@vue-storefront/core/lib/storage-manager'; +import { entityKeyName } from '@vue-storefront/core/lib/store/entities'; + +export const canCache = ({ includeFields, excludeFields }) => { + const isCacheable = includeFields === null && excludeFields === null; + + if (isCacheable) { + Logger.debug('Entity cache is enabled for productList')(); + } else { + Logger.debug('Entity cache is disabled for productList')(); + } + + return isCacheable; +}; + +const getCacheKey = (product, cacheByKey) => { + if (!product[cacheByKey]) { + cacheByKey = 'id'; + } + + return entityKeyName( + cacheByKey, + product[cacheByKey === 'sku' && product['parentSku'] ? 'parentSku' : cacheByKey] + ); // to avoid caching products by configurable_children.sku +}; + +export const configureChildren = product => { + if (product.configurable_children) { + for (let configurableChild of product.configurable_children) { + if (configurableChild.custom_attributes) { + for (let opt of configurableChild.custom_attributes) { + configurableChild[opt.attribute_code] = opt.value; + } + } + } + } + + return product; +}; + +export const storeProductToCache = (product, cacheByKey) => { + const cacheKey = getCacheKey(product, cacheByKey); + const cache = StorageManager.get('elasticCache'); + + cache + .setItem(cacheKey, product, null, config.products.disablePersistentProductsCache) + .catch(err => { + Logger.error('Cannot store cache for ' + cacheKey, err)(); + if (err.name === 'QuotaExceededError' || err.name === 'NS_ERROR_DOM_QUOTA_REACHED') { + // quota exceeded error + cache.clear(); // clear products cache if quota exceeded + } + }); +}; + +export const preConfigureProduct = ({ product, populateRequestCacheTags }) => { + const shouldPopulateCacheTags = populateRequestCacheTags && Vue.prototype.$cacheTags; + const isFirstVariantAsDefaultInURL = + config.products.setFirstVarianAsDefaultInURL && + product.hasOwnProperty('configurable_children') && + product.configurable_children.length > 0; + product.errors = {}; // this is an object to store validation result for custom options and others + product.info = {}; + + if (shouldPopulateCacheTags) { + Vue.prototype.$cacheTags.add(`P${product.id}`); + } + + if (!product.parentSku) { + product.parentSku = product.sku; + } + + if (isFirstVariantAsDefaultInURL) { + product.sku = product.configurable_children[0].sku; + } + + return product; +}; + +export const getOptimizedFields = ({ excludeFields, includeFields }) => { + if (config.entities.optimize) { + return { + excluded: excludeFields || config.entities.product.excludeFields, + included: includeFields || config.entities.product.includeFields + }; + } + + return { excluded: excludeFields, included: includeFields }; +}; + +export const isGroupedOrBundle = product => + product.type_id === 'grouped' || product.type_id === 'bundle'; diff --git a/core/modules/catalog/helpers/slugifyCategories.ts b/core/modules/catalog/helpers/slugifyCategories.ts new file mode 100644 index 000000000..73266fb8d --- /dev/null +++ b/core/modules/catalog/helpers/slugifyCategories.ts @@ -0,0 +1,33 @@ +import config from 'config' +import { slugify } from '@vue-storefront/core/helpers' +import { Category, ChildrenData } from '@vue-storefront/core/modules/catalog-next/types/Category' + +const createSlug = (category: ChildrenData): string => { + if (category.slug) { + return category.slug + } + + if (category.url_key && config.products.useMagentoUrlKeys) { + return category.url_key + } + + if (category.name) { + return `${slugify(category.name)}-${category.id}` + } + + return '' +} + +const slugifyCategories = (category: Category | ChildrenData): Category | ChildrenData => { + if (category.children_data) { + for (let subcat of category.children_data) { + if (subcat.name) { + return slugifyCategories({ ...subcat, slug: createSlug(subcat) } as any as ChildrenData) + } + } + } + + return category +} + +export default slugifyCategories diff --git a/core/modules/catalog/helpers/stock/getProductInfos.ts b/core/modules/catalog/helpers/stock/getProductInfos.ts new file mode 100644 index 000000000..66f646e90 --- /dev/null +++ b/core/modules/catalog/helpers/stock/getProductInfos.ts @@ -0,0 +1,7 @@ +const getProductInfos = (products) => products.map(product => ({ + is_in_stock: product.is_in_stock, + qty: product.qty, + product_id: product.product_id +})) + +export default getProductInfos diff --git a/core/modules/catalog/helpers/stock/getStatus.ts b/core/modules/catalog/helpers/stock/getStatus.ts new file mode 100644 index 000000000..552ffddf4 --- /dev/null +++ b/core/modules/catalog/helpers/stock/getStatus.ts @@ -0,0 +1,9 @@ +const getStatus = (product, defaultStatus) => { + if (product.stock) { + return product.stock.is_in_stock ? 'ok' : 'out_of_stock' + } + + return defaultStatus +} + +export default getStatus diff --git a/core/modules/catalog/helpers/stock/index.ts b/core/modules/catalog/helpers/stock/index.ts new file mode 100644 index 000000000..c25836333 --- /dev/null +++ b/core/modules/catalog/helpers/stock/index.ts @@ -0,0 +1,7 @@ +import getStatus from './getStatus' +import getProductInfos from './getProductInfos' + +export { + getStatus, + getProductInfos +} diff --git a/core/modules/catalog/helpers/tax.ts b/core/modules/catalog/helpers/tax.ts deleted file mode 100644 index 91d8688ba..000000000 --- a/core/modules/catalog/helpers/tax.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { Logger } from '@vue-storefront/core/lib/logger' - -function isSpecialPriceActive (fromDate, toDate) { - const now = new Date() - fromDate = fromDate ? new Date(fromDate) : false - toDate = toDate ? new Date(toDate) : false - - if (fromDate && toDate) { - return fromDate < now && toDate > now - } - - if (fromDate && !toDate) { - return fromDate < now - } - - if (!fromDate && toDate) { - return toDate > now - } -} - -export function updateProductPrices (product, rate, sourcePriceInclTax = false) { - const rateFactor = parseFloat(rate.rate) / 100 - product.price = parseFloat(product.price) - product.special_price = parseFloat(product.special_price) - - let priceExclTax = product.price - if (sourcePriceInclTax) { - priceExclTax = product.price / (1 + rateFactor) - product.price = priceExclTax - } - - product.priceTax = priceExclTax * rateFactor - product.priceInclTax = priceExclTax + product.priceTax - - if (!product.original_price) { - product.original_price = priceExclTax - product.original_price_incl_tax = product.price_incl_tax - product.original_price_tax = product.price_tax - } - - let specialPriceExclTax = product.special_price - if (sourcePriceInclTax) { - specialPriceExclTax = product.special_price / (1 + rateFactor) - product.special_price = specialPriceExclTax - } - - product.specialPriceTax = specialPriceExclTax * rateFactor - product.specialPriceInclTax = specialPriceExclTax + product.specialPriceTax - - if (product.special_price && (product.special_price < product.original_price)) { - if (!isSpecialPriceActive(product.special_from_date, product.special_to_date)) { - product.special_price = 0 // out of the dates period - } else { - product.originalPrice = priceExclTax - product.originalPriceInclTax = product.priceInclTax - product.originalPriceTax = product.priceTax - - product.price = specialPriceExclTax - product.priceInclTax = product.specialPriceInclTax - product.priceTax = product.specialPriceTax - } - } else { - product.special_price = 0 // the same price as original; it's not a promotion - } - - if (product.configurable_children) { - for (let configurableChild of product.configurable_children) { - if (configurableChild.custom_attributes) { - for (let opt of configurableChild.custom_attributes) { - configurableChild[opt.attribute_code] = opt.value - } - } - configurableChild.price = parseFloat(configurableChild.price) - configurableChild.special_price = parseFloat(configurableChild.special_price) - - let priceExclTax = configurableChild.price - if (sourcePriceInclTax) { - priceExclTax = configurableChild.price / (1 + rateFactor) - configurableChild.price = priceExclTax - } - - configurableChild.priceTax = priceExclTax * rateFactor - configurableChild.priceInclTax = priceExclTax + configurableChild.priceTax - - let specialPriceExclTax = parseFloat(configurableChild.special_price) - - if (sourcePriceInclTax) { - specialPriceExclTax = configurableChild.special_price / (1 + rateFactor) - configurableChild.special_price = specialPriceExclTax - } - - configurableChild.specialPriceTax = specialPriceExclTax * rateFactor - configurableChild.specialPriceInclTax = specialPriceExclTax + configurableChild.specialPriceTax - - if (configurableChild.special_price && (configurableChild.special_price < configurableChild.price)) { - if (!isSpecialPriceActive(configurableChild.special_from_date, configurableChild.special_to_date)) { - configurableChild.special_price = 0 // out of the dates period - } else { - configurableChild.originalPrice = priceExclTax - configurableChild.originalPriceInclTax = configurableChild.priceInclTax - configurableChild.originalPriceTax = configurableChild.priceTax - - configurableChild.price = specialPriceExclTax - configurableChild.priceInclTax = configurableChild.specialPriceInclTax - configurableChild.priceTax = configurableChild.specialPriceTax - } - } else { - configurableChild.special_price = 0 - } - - if (configurableChild.priceInclTax < product.priceInclTax || product.price === 0) { // always show the lowest price - product.priceInclTax = configurableChild.priceInclTax - product.priceTax = configurableChild.priceTax - product.price = configurableChild.price - product.special_price = configurableChild.special_price - product.specialPriceInclTax = configurableChild.specialPriceInclTax - product.specialPriceTax = configurableChild.specialPriceTax - product.originalPrice = configurableChild.originalPrice - product.originalPriceInclTax = configurableChild.originalPriceInclTax - product.originalPriceTax = configurableChild.originalPriceTax - } - } - } -} - -export function calculateProductTax (product, taxClasses, taxCountry = 'PL', taxRegion = '', sourcePriceInclTax = false) { - let rateFound = false - if (product.tax_class_id > 0) { - let taxClass = taxClasses.find((el) => el.product_tax_class_ids.indexOf(parseInt(product.tax_class_id) >= 0)) - if (taxClass) { - for (let rate of taxClass.rates) { // TODO: add check for zip code ranges (!) - if (rate.tax_country_id === taxCountry && (rate.region_name === taxRegion || rate.tax_region_id === 0 || !rate.region_name)) { - updateProductPrices(product, rate, sourcePriceInclTax) - rateFound = true - Logger.debug('Tax rate ' + rate.code + ' = ' + rate.rate + '% found for ' + taxCountry + ' / ' + taxRegion, 'helper-tax')() - break - } - } - } else { - Logger.debug('No such tax class id: ' + product.tax_class_id, 'helper-tax')() - } - } else { - Logger.debug('No tax class set for: ' + product.sku, 'helper-tax')() - } - if (!rateFound) { - Logger.log('No such tax class id: ' + product.tax_class_id + ' or rate not found for ' + taxCountry + ' / ' + taxRegion, 'helper-tax')() - updateProductPrices(product, {rate: 0}) - - product.priceInclTax = product.price - product.priceTax = 0 - product.specialPriceInclTax = 0 - product.specialPriceTax = 0 - if (product.configurable_children) { - for (let configurableChildren of product.configurable_children) { - configurableChildren.priceInclTax = configurableChildren.price - configurableChildren.priceTax = 0 - configurableChildren.specialPriceInclTax = 0 - configurableChildren.specialPriceTax = 0 - } - } - } -} diff --git a/core/modules/catalog/helpers/taxCalc.ts b/core/modules/catalog/helpers/taxCalc.ts new file mode 100644 index 000000000..bf8795f8a --- /dev/null +++ b/core/modules/catalog/helpers/taxCalc.ts @@ -0,0 +1,227 @@ +import camelCase from 'lodash-es/camelCase' + +// this is the mirror copy of taxcalc.js from VSF API + +function isSpecialPriceActive (fromDate, toDate) { + if (!fromDate && !toDate) { + return true + } + + const now = new Date() + fromDate = fromDate ? new Date(fromDate) : false + toDate = toDate ? new Date(toDate) : false + + if (fromDate && toDate) { + return fromDate < now && toDate > now + } + + if (fromDate && !toDate) { + return fromDate < now + } + + if (!fromDate && toDate) { + return toDate > now + } +} + +/** + * change object keys to camelCase + */ +function toCamelCase (obj: Record = {}): Record { + return Object.keys(obj).reduce((accObj, currKey) => { + accObj[camelCase(currKey)] = obj[currKey] + return accObj + }, {}) +} + +/** + * Create price object with base price and tax + * @param price - product price which is used to extract tax value + * @param rateFactor - tax % in decimal + * @param isPriceInclTax - determines if price already include tax + */ +function createSinglePrice (price: number, rateFactor: number, isPriceInclTax: boolean) { + const _price = isPriceInclTax ? price / (1 + rateFactor) : price + const tax = _price * rateFactor + + return { price: _price, tax } +} + +interface AssignPriceParams { + product: any, + target: string, + price: number, + tax?: number, + deprecatedPriceFieldsSupport?: boolean +} +/** + * assign price and tax to product with proper keys + * @param AssignPriceParams + */ +function assignPrice ({ product, target, price, tax = 0, deprecatedPriceFieldsSupport = true }: AssignPriceParams): void { + let priceUpdate = { + [target]: price, + [`${target}_tax`]: tax, + [`${target}_incl_tax`]: price + tax + } + + if (deprecatedPriceFieldsSupport) { + /** BEGIN @deprecated - inconsitent naming kept just for the backward compatibility */ + priceUpdate = Object.assign(priceUpdate, toCamelCase(priceUpdate)) + /** END */ + } + + Object.assign(product, priceUpdate) +} + +export function updateProductPrices ({ product, rate, sourcePriceInclTax = false, deprecatedPriceFieldsSupport = false, finalPriceInclTax = true }) { + const rate_factor = parseFloat(rate.rate) / 100 + const hasOriginalPrices = ( + product.hasOwnProperty('original_price') && + product.hasOwnProperty('original_final_price') && + product.hasOwnProperty('original_special_price') + ) + // build objects with original price and tax + // for first calculation use `price`, for next one use `original_price` + const priceWithTax = createSinglePrice(parseFloat(product.original_price || product.price), rate_factor, sourcePriceInclTax && !hasOriginalPrices) + const finalPriceWithTax = createSinglePrice(parseFloat(product.original_final_price || product.final_price), rate_factor, finalPriceInclTax && !hasOriginalPrices) + const specialPriceWithTax = createSinglePrice(parseFloat(product.original_special_price || product.special_price), rate_factor, sourcePriceInclTax && !hasOriginalPrices) + + // save original prices + if (!hasOriginalPrices) { + assignPrice({product, target: 'original_price', ...priceWithTax, deprecatedPriceFieldsSupport}) + + if (specialPriceWithTax.price) { + product.original_special_price = specialPriceWithTax.price + } + + if (finalPriceWithTax.price) { + product.original_final_price = finalPriceWithTax.price + } + } + + // reset previous calculation + assignPrice({ product, target: 'price', ...priceWithTax, deprecatedPriceFieldsSupport }) + + if (specialPriceWithTax.price) { + assignPrice({ product, target: 'special_price', ...specialPriceWithTax, deprecatedPriceFieldsSupport }) + } + if (finalPriceWithTax.price) { + assignPrice({ product, target: 'final_price', ...finalPriceWithTax, deprecatedPriceFieldsSupport }) + } + + if (product.final_price) { + if (product.final_price < product.price) { // compare the prices with the product final price if provided; final prices is used in case of active catalog promo rules for example + assignPrice({product, target: 'price', ...finalPriceWithTax, deprecatedPriceFieldsSupport}) + if (product.special_price && product.final_price < product.special_price) { // for VS - special_price is any price lowered than regular price (`price`); in Magento there is a separate mechanism for setting the `special_prices` + assignPrice({product, target: 'price', ...specialPriceWithTax, deprecatedPriceFieldsSupport}) // if the `final_price` is lower than the original `special_price` - it means some catalog rules were applied over it + assignPrice({product, target: 'special_price', ...finalPriceWithTax, deprecatedPriceFieldsSupport}) + } else { + assignPrice({product, target: 'price', ...finalPriceWithTax, deprecatedPriceFieldsSupport}) + } + } + } + + if (product.special_price && (product.special_price < product.original_price)) { + if (!isSpecialPriceActive(product.special_from_date, product.special_to_date)) { + // out of the dates period + assignPrice({ product, target: 'special_price', price: 0, tax: 0, deprecatedPriceFieldsSupport }) + } else { + assignPrice({ product, target: 'price', ...specialPriceWithTax, deprecatedPriceFieldsSupport }) + } + } else { + // the same price as original; it's not a promotion + assignPrice({ product, target: 'special_price', price: 0, tax: 0, deprecatedPriceFieldsSupport }) + } + + if (product.configurable_children) { + for (let configurableChild of product.configurable_children) { + if (configurableChild.custom_attributes) { + for (let opt of configurableChild.custom_attributes) { + configurableChild[opt.attribute_code] = opt.value + } + } + + // update children prices + updateProductPrices({ product: configurableChild, rate, sourcePriceInclTax, deprecatedPriceFieldsSupport, finalPriceInclTax }) + + if ((configurableChild.price_incl_tax <= product.price_incl_tax) || product.price === 0) { // always show the lowest price + assignPrice({ + product, + target: 'price', + price: configurableChild.price, + tax: configurableChild.price_tax, + deprecatedPriceFieldsSupport + }) + assignPrice({ + product, + target: 'special_price', + price: configurableChild.special_price, + tax: configurableChild.special_price_tax, + deprecatedPriceFieldsSupport + }) + } + } + } +} + +export function calculateProductTax ({ product, taxClasses, taxCountry = 'PL', taxRegion = '', sourcePriceInclTax = false, deprecatedPriceFieldsSupport = false, finalPriceInclTax = true, userGroupId = null, isTaxWithUserGroupIsActive }) { + let rateFound = false + if (product.tax_class_id > 0) { + let taxClass + if (isTaxWithUserGroupIsActive) { + taxClass = taxClasses.find((el) => + el.product_tax_class_ids.indexOf(parseInt(product.tax_class_id)) >= 0 && + el.customer_tax_class_ids.indexOf(userGroupId) >= 0 + ) + } else { + taxClass = taxClasses.find((el) => el.product_tax_class_ids.indexOf(parseInt(product.tax_class_id) >= 0)) + } + + if (taxClass) { + for (let rate of taxClass.rates) { // TODO: add check for zip code ranges (!) + if (rate.tax_country_id === taxCountry && (rate.region_name === taxRegion || rate.tax_region_id === 0 || !rate.region_name)) { + updateProductPrices({ product, rate, sourcePriceInclTax, deprecatedPriceFieldsSupport, finalPriceInclTax }) + rateFound = true + break + } + } + } + } + if (!rateFound) { + updateProductPrices({ product, rate: { rate: 0 }, sourcePriceInclTax, deprecatedPriceFieldsSupport, finalPriceInclTax }) + + product.price_incl_tax = product.price + product.price_tax = 0 + product.special_price_incl_tax = 0 + product.special_price_tax = 0 + + if (deprecatedPriceFieldsSupport) { + /** BEGIN @deprecated - inconsitent naming kept just for the backward compatibility */ + product.priceInclTax = product.price + product.priceTax = 0 + product.specialPriceInclTax = 0 + product.specialPriceTax = 0 + /** END */ + } + + if (product.configurable_children) { + for (let configurableChildren of product.configurable_children) { + configurableChildren.price_incl_tax = configurableChildren.price + configurableChildren.price_tax = 0 + configurableChildren.special_price_incl_tax = 0 + configurableChildren.special_price_tax = 0 + + if (deprecatedPriceFieldsSupport) { + /** BEGIN @deprecated - inconsitent naming kept just for the backward compatibility */ + configurableChildren.priceInclTax = configurableChildren.price + configurableChildren.priceTax = 0 + configurableChildren.specialPriceInclTax = 0 + configurableChildren.specialPriceTax = 0 + /** END */ + } + } + } + } + return product +} diff --git a/core/modules/catalog/hooks/beforeRegistration.ts b/core/modules/catalog/hooks/beforeRegistration.ts deleted file mode 100644 index 9a529e6c2..000000000 --- a/core/modules/catalog/hooks/beforeRegistration.ts +++ /dev/null @@ -1,26 +0,0 @@ -import * as localForage from 'localforage' -import UniversalStorage from '@vue-storefront/core/store/lib/storage' -import { currentStoreView } from '@vue-storefront/core/lib/multistore' - -export function beforeRegistration ({ Vue, config, store, isServer }) { - const storeView = currentStoreView() - const dbNamePrefix = storeView.storeCode ? storeView.storeCode + '-' : '' - - Vue.prototype.$db.categoriesCollection = new UniversalStorage(localForage.createInstance({ - name: dbNamePrefix + 'shop', - storeName: 'categories', - driver: localForage[config.localForage.defaultDrivers['categories']] - })) - - Vue.prototype.$db.attributesCollection = new UniversalStorage(localForage.createInstance({ - name: dbNamePrefix + 'shop', - storeName: 'attributes', - driver: localForage[config.localForage.defaultDrivers['attributes']] - })) - - Vue.prototype.$db.elasticCacheCollection = new UniversalStorage(localForage.createInstance({ - name: dbNamePrefix + 'shop', - storeName: 'elasticCache', - driver: localForage[config.localForage.defaultDrivers['elasticCache']] - }), true, config.server.elasticCacheQuota) -} diff --git a/core/modules/catalog/hooks/index.ts b/core/modules/catalog/hooks/index.ts new file mode 100644 index 000000000..a2b59ef24 --- /dev/null +++ b/core/modules/catalog/hooks/index.ts @@ -0,0 +1,20 @@ +import { createMutatorHook } from '@vue-storefront/core/lib/hooks' +import Product from '../types/Product'; + +const { + hook: beforeTaxesCalculatedHook, + executor: beforeTaxesCalculatedExecutor +} = createMutatorHook() + +const catalogHooksExecutors = { + beforeTaxesCalculated: beforeTaxesCalculatedExecutor +} + +const catalogHooks = { + beforeTaxesCalculated: beforeTaxesCalculatedHook +} + +export { + catalogHooks, + catalogHooksExecutors +} diff --git a/core/modules/catalog/index.ts b/core/modules/catalog/index.ts index a29cd6fff..9d5757872 100644 --- a/core/modules/catalog/index.ts +++ b/core/modules/catalog/index.ts @@ -1,20 +1,42 @@ +import { StorefrontModule } from '@vue-storefront/core/lib/modules' import { productModule } from './store/product' import { attributeModule } from './store/attribute' import { stockModule } from './store/stock' import { taxModule } from './store/tax' import { categoryModule } from './store/category' -import { createModule } from '@vue-storefront/core/lib/module' -import { beforeRegistration } from './hooks/beforeRegistration' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' +import config from 'config' +import { filterChangedProduct, productAfterCustomoptions, productAfterBundleoptions, productAfterPriceupdate, onUserPricesRefreshed } from './events' +import { isServer } from '@vue-storefront/core/helpers' +import uniq from 'lodash-es/uniq' -export const KEY = 'catalog' -export const Catalog = createModule({ - key: KEY, - store: { modules: [ - { key: 'product', module: productModule }, - { key: 'attribute', module: attributeModule }, - { key: 'stock', module: stockModule }, - { key: 'tax', module: taxModule }, - { key: 'category', module: categoryModule } - ] }, - beforeRegistration -}) +export const CatalogModule: StorefrontModule = async function ({store, router, appConfig}) { + StorageManager.init('categories') + StorageManager.init('attributes') + StorageManager.init('products') + StorageManager.init('elasticCache', true, appConfig.server.elasticCacheQuota) + + store.registerModule('product', productModule) + store.registerModule('attribute', attributeModule) + store.registerModule('stock', stockModule) + 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 (!isServer) { + // Things moved from Product.js + EventBus.$on('product-after-priceupdate', product => productAfterPriceupdate(product, store)) + EventBus.$on('filter-changed-product', filterOptions => filterChangedProduct(filterOptions, store, router)) + EventBus.$on('product-after-customoptions', payload => productAfterCustomoptions(payload, store)) + EventBus.$on('product-after-bundleoptions', payload => productAfterBundleoptions(payload, store)) + + if (config.usePriceTiers || store.getters['tax/getIsUserGroupedTaxActive']) { + EventBus.$on('user-after-loggedin', onUserPricesRefreshed.bind(null, store, router)) + EventBus.$on('user-after-logout', onUserPricesRefreshed.bind(null, store, router)) + } + } +} diff --git a/core/modules/catalog/store/attribute/actions.ts b/core/modules/catalog/store/attribute/actions.ts index 813b25ffe..260fa14e4 100644 --- a/core/modules/catalog/store/attribute/actions.ts +++ b/core/modules/catalog/store/attribute/actions.ts @@ -1,53 +1,86 @@ import * as types from './mutation-types' -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' import { quickSearchByQuery } from '@vue-storefront/core/lib/search' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' import AttributeState from '../../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 areAttributesAlreadyLoaded from './../../helpers/areAttributesAlreadyLoaded' +import createAttributesListQuery from './../../helpers/createAttributesListQuery' +import reduceAttributesLists from './../../helpers/reduceAttributesLists' const actions: ActionTree = { + async updateAttributes ({ commit, getters }, { attributes }) { + const idsList = getters.getAttributeListById + const codesList = getters.getAttributeListByCode + + for (let attr of attributes) { + if (attr && !config.attributes.disablePersistentAttributesCache) { + const attrCollection = StorageManager.get('attributes') + + try { + await attrCollection.setItem(entityKeyName('attribute_code', attr.attribute_code.toLowerCase()), attr) + await attrCollection.setItem(entityKeyName('attribute_id', attr.attribute_id.toString()), attr) + } catch (e) { + Logger.error(e, 'mutations')() + } + } + } + + commit(types.ATTRIBUTE_UPD_ATTRIBUTES, reduceAttributesLists({ codesList, idsList, attributes })) + }, + async loadCachedAttributes ({ dispatch }, { filterField, filterValues }) { + if (!filterValues) { + return + } + + const attributes = await prefetchCachedAttributes(filterField, filterValues) + + if (attributes) { + await dispatch('updateAttributes', { attributes }) + } + }, + updateBlacklist ({ commit, getters }, { filterValues, filterField, attributes }) { + if (attributes && filterValues.length > 0) { + const foundValues = attributes.map(attr => attr[filterField]) + const toBlackList = filterValues.filter(ofv => !foundValues.includes(ofv) && !getters.getBlacklist.includes(ofv)) + commit(types.ATTRIBUTE_UPD_BLACKLIST, toBlackList) + } + }, /** * Load attributes with specific codes * @param {Object} context * @param {Array} attrCodes attribute codes to load */ - list (context, { filterValues = null, filterField = 'attribute_code', only_user_defined = false, only_visible = false, size = 150, start = 0, includeFields = config.entities.optimize ? config.entities.attribute.includeFields : null }) { - const commit = context.commit - - let searchQuery = new SearchQuery() - const orgFilterValues = filterValues ? [...filterValues] : [] - if (filterValues) { - filterValues = filterValues.filter(fv => { // check the already loaded - if (context.state.blacklist !== null && context.state.blacklist.includes(fv)) return false // return that this attribute is not on our blacklist - if (filterField === 'attribute_id') return (typeof context.state.list_by_id[fv] === 'undefined' || context.state.list_by_id[fv] === null) - if (filterField === 'attribute_code') return (typeof context.state.list_by_code[fv] === 'undefined' || context.state.list_by_code[fv] === null) - }) - if (!filterValues || filterValues.length === 0) { - Logger.info('Skipping attribute load - attributes already loaded', 'attr', { orgFilterValues, filterField })() - return Promise.resolve({ - items: Object.values(context.state.list_by_code) - }) - } - searchQuery = searchQuery.applyFilter({key: filterField, value: {'in': filterValues}}) - } - if (only_user_defined) { - searchQuery = searchQuery.applyFilter({key: 'is_user_defined', value: {'in': [true]}}) - } - if (only_visible) { - searchQuery = searchQuery.applyFilter({key: 'is_visible', value: {'in': [true]}}) + async list ({ getters, dispatch }, { filterValues = null, filterField = 'attribute_code', only_user_defined = false, only_visible = false, size = 150, start = 0, includeFields = config.entities.optimize ? config.entities.attribute.includeFields : null }) { + const blacklist = getters.getBlacklist + const idsList = getters.getAttributeListById + const codesList = getters.getAttributeListByCode + const orgFilterValues = filterValues || [] + + await dispatch('loadCachedAttributes', { filterField, filterValues }) + + if (areAttributesAlreadyLoaded({ filterValues, filterField, blacklist, idsList, codesList })) { + Logger.info('Skipping attribute load - attributes already loaded', 'attr', { orgFilterValues, filterField })() + return { items: Object.values(codesList) } } - return quickSearchByQuery({ entityType: 'attribute', query: searchQuery, includeFields: includeFields }).then((resp) => { - if (resp && Array.isArray(orgFilterValues) && orgFilterValues.length > 0) { - const foundValues = resp.items.map(attr => attr[filterField]) - const toBlackList = filterValues.filter(ofv => !foundValues.includes(ofv)) - toBlackList.map(tbl => { - if (!context.state.blacklist.includes(tbl)) context.state.blacklist.push(tbl) - }) // extend the black list of not-found atrbiutes - } - commit(types.ATTRIBUTE_UPD_ATTRIBUTES, resp) + + const query = createAttributesListQuery({ + filterValues, + filterField, + onlyDefinedByUser: only_user_defined, + onlyVisible: only_visible }) + const resp = await quickSearchByQuery({ entityType: 'attribute', query, includeFields, start, size }) + const attributes = resp && orgFilterValues.length > 0 ? resp.items : null + + dispatch('updateBlacklist', { filterValues, filterField, attributes }) + await dispatch('updateAttributes', { attributes }) + + return resp } } diff --git a/core/modules/catalog/store/attribute/getters.ts b/core/modules/catalog/store/attribute/getters.ts index 920d0fbf7..bda43dcc0 100644 --- a/core/modules/catalog/store/attribute/getters.ts +++ b/core/modules/catalog/store/attribute/getters.ts @@ -1,10 +1,20 @@ import { GetterTree } from 'vuex' import AttributeState from '../../types/AttributeState' import RootState from '@vue-storefront/core/types/RootState' +import { Logger } from '@vue-storefront/core/lib/logger' const getters: GetterTree = { - attributeListByCode: (state) => state.list_by_code, - attributeListById: (state) => state.list_by_id + getAttributeListByCode: (state) => state.list_by_code, + getAttributeListById: (state) => state.list_by_id, + // @deprecated + attributeListByCode: (state, getters) => getters.getAttributeListByCode, + // @deprecated + attributeListById: (state, getters) => getters.getAttributeListById, + getBlacklist: (state) => state.blacklist, + getAllComparableAttributes: (state, getters) => { + const attributesByCode = getters.getAttributeListByCode + return Object.values(attributesByCode).filter((a: any) => ["1", true].includes(a.is_comparable)) //In some cases we get boolean instead of "0"/"1" that why we support both options + } } export default getters diff --git a/core/modules/catalog/store/attribute/mutation-types.ts b/core/modules/catalog/store/attribute/mutation-types.ts index 018e93ca0..67254b063 100644 --- a/core/modules/catalog/store/attribute/mutation-types.ts +++ b/core/modules/catalog/store/attribute/mutation-types.ts @@ -1,2 +1,3 @@ export const SN_ATTRIBUTE = 'attribute' export const ATTRIBUTE_UPD_ATTRIBUTES = SN_ATTRIBUTE + '/UPD_ATTRIBUTES' +export const ATTRIBUTE_UPD_BLACKLIST = SN_ATTRIBUTE + '/UPD_BLACKLIST_ATTRIBUTES' diff --git a/core/modules/catalog/store/attribute/mutations.ts b/core/modules/catalog/store/attribute/mutations.ts index 79c0c94a3..1a7f23a99 100644 --- a/core/modules/catalog/store/attribute/mutations.ts +++ b/core/modules/catalog/store/attribute/mutations.ts @@ -1,9 +1,8 @@ import Vue from 'vue' import { MutationTree } from 'vuex' -import { entityKeyName } from '@vue-storefront/core/store/lib/entities' import * as types from './mutation-types' import AttributeState from '../../types/AttributeState' -import { Logger } from '@vue-storefront/core/lib/logger' +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' const mutations: MutationTree = { /** @@ -11,30 +10,13 @@ const mutations: MutationTree = { * @param {} state * @param {Array} attributes */ - [types.ATTRIBUTE_UPD_ATTRIBUTES] (state, attributes) { - let attrList = attributes.items // extract fields from ES _source - let attrHashByCode = state.list_by_code - let attrHashById = state.list_by_id - - for (let attr of attrList) { - attrHashByCode[attr.attribute_code] = attr - attrHashById[attr.attribute_id] = attr - - const attrCollection = Vue.prototype.$db.attributesCollection - try { - attrCollection.setItem(entityKeyName('attribute_code', attr.attribute_code.toLowerCase()), attr).catch((reason) => { - Logger.error(reason, 'mutations') // it doesn't work on SSR - }) // populate cache by slug - attrCollection.setItem(entityKeyName('attribute_id', attr.attribute_id.toString()), attr).catch((reason) => { - Logger.error(reason, 'mutations') // it doesn't work on SSR - }) // populate cache by id - } catch (e) { - Logger.error(e, 'mutations')() - } - } + async [types.ATTRIBUTE_UPD_ATTRIBUTES] (state, { attrHashByCode, attrHashById }) { Vue.set(state, 'list_by_code', attrHashByCode) Vue.set(state, 'list_by_id', attrHashById) - Vue.prototype.$bus.$emit('product-after-attributes-loaded') + EventBus.$emit('product-after-attributes-loaded') + }, + [types.ATTRIBUTE_UPD_BLACKLIST] (state, blacklist) { + state.blacklist = state.blacklist.concat(blacklist) } } diff --git a/core/modules/catalog/store/category/actions.ts b/core/modules/catalog/store/category/actions.ts index 2ee25c3a7..ea228e647 100644 --- a/core/modules/catalog/store/category/actions.ts +++ b/core/modules/catalog/store/category/actions.ts @@ -2,7 +2,7 @@ import Vue from 'vue' import { ActionTree } from 'vuex' import * as types from './mutation-types' import { quickSearchByQuery } from '@vue-storefront/core/lib/search' -import { entityKeyName } from '@vue-storefront/core/store/lib/entities' +import { entityKeyName } from '@vue-storefront/core/lib/store/entities' import rootStore from '@vue-storefront/core/store' import i18n from '@vue-storefront/i18n' import chunk from 'lodash-es/chunk' @@ -12,10 +12,13 @@ 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 } from '@vue-storefront/core/lib/multistore' +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' import config from 'config' +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' const actions: ActionTree = { @@ -27,74 +30,52 @@ const actions: ActionTree = { context.commit(types.CATEGORY_UPD_CURRENT_CATEGORY_PATH, []) context.commit(types.CATEGORY_UPD_CURRENT_CATEGORY, {}) rootStore.dispatch('stock/clearCache') - Vue.prototype.$bus.$emit('category-after-reset', { }) + EventBus.$emit('category-after-reset', { }) }, /** * Load categories within specified parent * @param {Object} commit promise * @param {Object} parent parent category */ - list (context, { 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, updateState = true }) { - const commit = context.commit - let customizedQuery = false // that means the parameteres are != defaults; with defaults parameter the data could be get from window.__INITIAL_STATE__ - this is optimisation trick - let searchQuery = new SearchQuery() - if (parent && typeof parent !== 'undefined') { - searchQuery = searchQuery.applyFilter({key: 'parent_id', value: { 'eq': typeof parent === 'object' ? parent.id : parent }}) - customizedQuery = true - } - if (level !== null) { - searchQuery = searchQuery.applyFilter({key: 'level', value: {'eq': level}}) - if (level !== config.entities.category.categoriesDynamicPrefetchLevel && !isServer) { // if this is the default level we're getting the results from window.__INITIAL_STATE__ not querying the server - customizedQuery = true - } - } + async list ({ commit, state, 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, updateState = true }) { + const { searchQuery, isCustomizedQuery } = createCategoryListQuery({ parent, level, key, value, onlyActive, onlyNotEmpty }) + const shouldLoadCategories = skipCache || ((!state.list || state.list.length === 0) || isCustomizedQuery) - if (key !== null) { - if (Array.isArray(value)) { - searchQuery = searchQuery.applyFilter({key: key, value: {'in': value}}) - } else { - searchQuery = searchQuery.applyFilter({key: key, value: {'eq': value}}) + if (shouldLoadCategories) { + const resp = await quickSearchByQuery({ entityType: 'category', query: searchQuery, sort, size, start, includeFields, excludeFields }) + + if (updateState) { + await dispatch('registerCategoryMapping', { categories: resp.items }) + + commit(types.CATEGORY_UPD_CATEGORIES, { ...resp, includeFields, excludeFields }) + EventBus.$emit('category-after-list', { query: searchQuery, sort, size, start, list: resp }) } - customizedQuery = true - } - if (onlyActive === true) { - searchQuery = searchQuery.applyFilter({key: 'is_active', value: {'eq': true}}) + return resp } - if (onlyNotEmpty === true) { - searchQuery = searchQuery.applyFilter({key: 'product_count', value: {'gt': 0}}) - customizedQuery = true + const list = { items: state.list, total: state.list.length } + + if (updateState) { + EventBus.$emit('category-after-list', { query: searchQuery, sort, size, start, list }) } - if (skipCache || ((!context.state.list || context.state.list.length === 0) || customizedQuery)) { - return quickSearchByQuery({ entityType: 'category', query: searchQuery, sort: sort, size: size, start: start, includeFields: includeFields, excludeFields: excludeFields }).then((resp) => { - for (let category of resp.items) { - if (category.url_path && updateState) { - rootStore.dispatch('url/registerMapping', { - url: localizedDispatcherRoute(category.url_path, currentStoreView().storeCode), - routeData: { - params: { - 'slug': category.slug - }, - 'name': 'category' - } - }, { root: true }) + + return list + }, + async registerCategoryMapping ({ dispatch }, { categories }) { + const { storeCode, appendStoreCode } = currentStoreView() + for (let category of categories) { + if (category.url_path) { + await dispatch('url/registerMapping', { + url: localizedDispatcherRoute(category.url_path, storeCode), + routeData: { + params: { + 'slug': category.slug + }, + 'name': localizedDispatcherRouteName('category', storeCode, appendStoreCode) } - } - if (updateState) { - commit(types.CATEGORY_UPD_CATEGORIES, Object.assign(resp, { includeFields, excludeFields })) - Vue.prototype.$bus.$emit('category-after-list', { query: searchQuery, sort: sort, size: size, start: start, list: resp }) - } - return resp - }) - } else { - return new Promise((resolve, reject) => { - let resp = { items: context.state.list, total: context.state.list.length } - if (updateState) { - Vue.prototype.$bus.$emit('category-after-list', { query: searchQuery, sort: sort, size: size, start: start, list: resp }) - } - resolve(resp) - }) + }, { root: true }) + } } }, @@ -149,15 +130,13 @@ const actions: ActionTree = { } if (category.parent_id >= config.entities.category.categoriesRootCategorylId) { dispatch('single', { key: 'id', value: category.parent_id, setCurrentCategory: false, setCurrentCategoryPath: false }).then((sc) => { // TODO: move it to the server side for one requests OR cache in indexedDb - if (!sc) { + if (!sc || sc.parent_id === sc.id) { commit(types.CATEGORY_UPD_CURRENT_CATEGORY_PATH, currentPath) - Vue.prototype.$bus.$emit('category-after-single', { category: mainCategory }) + EventBus.$emit('category-after-single', { category: mainCategory }) return resolve(mainCategory) } currentPath.unshift(sc) - if (sc.parent_id) { - recurCatFinder(sc) - } + recurCatFinder(sc) }).catch(err => { Logger.error(err)() commit(types.CATEGORY_UPD_CURRENT_CATEGORY_PATH, currentPath) // this is the case when category is not binded to the root tree - for example 'Erin Recommends' @@ -165,7 +144,7 @@ const actions: ActionTree = { }) } else { commit(types.CATEGORY_UPD_CURRENT_CATEGORY_PATH, currentPath) - Vue.prototype.$bus.$emit('category-after-single', { category: mainCategory }) + EventBus.$emit('category-after-single', { category: mainCategory }) resolve(mainCategory) } } @@ -175,7 +154,7 @@ const actions: ActionTree = { reject(new Error('Category query returned empty result ' + key + ' = ' + value)) } } else { - Vue.prototype.$bus.$emit('category-after-single', { category: mainCategory }) + EventBus.$emit('category-after-single', { category: mainCategory }) resolve(mainCategory) } } @@ -193,7 +172,7 @@ const actions: ActionTree = { if (skipCache || isServer) { fetchCat({ key, value }) } else { - const catCollection = Vue.prototype.$db.categoriesCollection + const catCollection = StorageManager.get('categories') // Check if category does not exist in the store AND we haven't recursively reached Default category (id=1) catCollection.getItem(entityKeyName(key, value), setcat) } diff --git a/core/modules/catalog/store/category/mutations.ts b/core/modules/catalog/store/category/mutations.ts index 2514b27b1..d749ae0cc 100644 --- a/core/modules/catalog/store/category/mutations.ts +++ b/core/modules/catalog/store/category/mutations.ts @@ -1,59 +1,58 @@ import Vue from 'vue' import { MutationTree } from 'vuex' import * as types from './mutation-types' -import { slugify, formatBreadCrumbRoutes } from '@vue-storefront/core/helpers' -import { entityKeyName } from '@vue-storefront/core/store/lib/entities' +import { formatBreadCrumbRoutes } from '@vue-storefront/core/helpers' +import { entityKeyName } from '@vue-storefront/core/lib/store/entities' import CategoryState from '../../types/CategoryState' -import config from 'config' import { Logger } from '@vue-storefront/core/lib/logger' +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' +import slugifyCategories from '@vue-storefront/core/modules/catalog/helpers/slugifyCategories' const mutations: MutationTree = { [types.CATEGORY_UPD_CURRENT_CATEGORY] (state, category) { state.current = category - Vue.prototype.$bus.$emit('category-after-current', { category: category }) + EventBus.$emit('category-after-current', { category: category }) }, [types.CATEGORY_UPD_CURRENT_CATEGORY_PATH] (state, path) { state.current_path = path // TODO: store to cache state.breadcrumbs.routes = formatBreadCrumbRoutes(state.current_path) }, [types.CATEGORY_UPD_CATEGORIES] (state, categories) { + const catCollection = StorageManager.get('categories') + for (let category of categories.items) { - 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.slug ? subcat.slug : ((subcat.hasOwnProperty('url_key') && config.products.useMagentoUrlKeys) ? subcat.url_key : (subcat.hasOwnProperty('name') ? slugify(subcat.name) + '-' + subcat.id : '')) }) - catSlugSetter(subcat) - } - } - } + category = slugifyCategories(category) + const catExist = state.list.find(existingCat => existingCat.id === category.id) + + if (!catExist) { + state.list.push(category) } - catSlugSetter(category) - if (categories.includeFields == null) { - const catCollection = Vue.prototype.$db.categoriesCollection + + if (!categories.includeFields) { try { - catCollection.setItem(entityKeyName('slug', category.slug.toLowerCase()), category).catch((reason) => { - Logger.error(reason, 'category') // it doesn't work on SSR - }) // populate cache by slug - catCollection.setItem(entityKeyName('id', category.id), category).catch((reason) => { - Logger.error(reason, 'category') // it doesn't work on SSR - }) // populate cache by id + catCollection + .setItem(entityKeyName('slug', category.slug.toLowerCase()), category) + .catch(reason => Logger.error(reason, 'category')) + + catCollection + .setItem(entityKeyName('id', category.id), category) + .catch(reason => Logger.error(reason, 'category')) } catch (e) { Logger.error(e, 'category')() } } } - if (state.list) { - categories.items.map(newCat => { - if (!state.list.find(existingCat => existingCat.id === newCat.id)) { - state.list.push(newCat) - } - }) - } else { - state.list = categories.items - } + + state.list.sort((catA, catB) => { + if (catA.position && catB.position) { + if (catA.position < catB.position) return -1 + if (catA.position > catB.position) return 1 + } + return 0 + }) }, - [types.CATEGORY_ADD_AVAILABLE_FILTER] (state, {key, options = []}) { + [types.CATEGORY_ADD_AVAILABLE_FILTER] (state, { key, options = [] }) { Vue.set(state.filters.available, key, options) }, [types.CATEGORY_REMOVE_FILTERS] (state) { diff --git a/core/modules/catalog/store/product/actions.ts b/core/modules/catalog/store/product/actions.ts index 6b6eb6c22..35c929038 100644 --- a/core/modules/catalog/store/product/actions.ts +++ b/core/modules/catalog/store/product/actions.ts @@ -1,24 +1,25 @@ import Vue from 'vue' import { ActionTree } from 'vuex' import * as types from './mutation-types' -import { formatBreadCrumbRoutes, productThumbnailPath, isServer } from '@vue-storefront/core/helpers' -import { currentStoreView, localizedDispatcherRoute } from '@vue-storefront/core/lib/multistore' +import { formatBreadCrumbRoutes, isServer } from '@vue-storefront/core/helpers' +import { currentStoreView, localizedDispatcherRoute, localizedDispatcherRouteName } from '@vue-storefront/core/lib/multistore' import { configureProductAsync, doPlatformPricesSync, filterOutUnavailableVariants, - calculateTaxes, 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/store/lib/entities' +import { entityKeyName } from '@vue-storefront/core/lib/store/entities' import { optionLabel } from '../../helpers/optionLabel' -import { quickSearchByQuery, isOnline } from '@vue-storefront/core/lib/search' +import { isOnline } from '@vue-storefront/core/lib/search' import omit from 'lodash-es/omit' import trim from 'lodash-es/trim' +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' @@ -27,6 +28,9 @@ 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' const PRODUCT_REENTER_TIMEOUT = 20000 @@ -36,8 +40,8 @@ const actions: ActionTree = { * Reset current configuration and selected variatnts */ reset (context) { - const productOriginal = context.getters.productOriginal - context.commit(types.CATALOG_RESET_PRODUCT, productOriginal) + const originalProduct = Object.assign({}, context.getters.getOriginalProduct) + context.commit(types.PRODUCT_RESET_CURRENT, originalProduct) }, /** * Setup product breadcrumbs path @@ -104,9 +108,14 @@ const actions: ActionTree = { /** * Download Magento2 / other platform prices to put them over ElasticSearch prices */ - syncPlatformPricesOver (context, { skus }) { + syncPlatformPricesOver ({ rootGetters }, { skus }) { const storeView = currentStoreView() - return TaskQueue.execute({ url: config.products.endpoint + '/render-list?skus=' + encodeURIComponent(skus.join(',')) + '¤cyCode=' + encodeURIComponent(storeView.i18n.currencyCode) + '&storeId=' + encodeURIComponent(storeView.storeId), // sync the cart + 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' }, @@ -124,7 +133,7 @@ const actions: ActionTree = { let subloaders = [] if (product.type_id === 'grouped') { product.price = 0 - product.priceInclTax = 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) { @@ -140,7 +149,7 @@ const actions: ActionTree = { pl.product = asocProd pl.product.qty = 1 product.price += pl.product.price - product.priceInclTax += pl.product.priceInclTax + 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)() @@ -154,7 +163,7 @@ const actions: ActionTree = { } if (product.type_id === 'bundle') { product.price = 0 - product.priceInclTax = 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) { @@ -174,7 +183,7 @@ const actions: ActionTree = { if (pl.id === defaultOption.id) { product.price += pl.product.price * pl.product.qty - product.priceInclTax += pl.product.priceInclTax * pl.product.qty + product.price_incl_tax += pl.product.price_incl_tax * pl.product.qty product.tax += pl.product.tax * pl.product.qty } } else { @@ -196,12 +205,12 @@ const actions: ActionTree = { Logger.log('Checking configurable parent')() let searchQuery = new SearchQuery() - searchQuery = searchQuery.applyFilter({key: 'configurable_children.sku', value: {'eq': context.state.current.sku}}) + 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.CATALOG_SET_PRODUCT_PARENT, parentProduct) + context.commit(types.PRODUCT_SET_PARENT, parentProduct) } }).catch((err) => { Logger.error(err)() @@ -236,17 +245,16 @@ const actions: ActionTree = { let subloaders = [] if (product.type_id === 'configurable' && product.hasOwnProperty('configurable_options')) { subloaders.push(context.dispatch('product/loadConfigurableAttributes', { product }, { root: true }).then((attributes) => { - context.state.current_options = { - } + 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 (!context.state.current_options[optionKey]) { - context.state.current_options[optionKey] = [] + if (!productOptions[optionKey]) { + productOptions[optionKey] = [] } - context.state.current_options[optionKey].push({ + productOptions[optionKey].push({ label: lb, id: ov.value_index, attribute_code: option.attribute_code @@ -254,8 +262,8 @@ const actions: ActionTree = { } } } - Vue.set(context.state, 'current_options', context.state.current_options) - let selectedVariant = context.state.current + context.commit(types.PRODUCT_SET_CURRENT_OPTIONS, productOptions) + let selectedVariant = context.getters.getCurrentProduct populateProductConfigurationAsync(context, { selectedVariant: selectedVariant, product: product }) }).catch(err => { Logger.error(err)() @@ -275,97 +283,82 @@ const actions: ActionTree = { * @param {Int} size page size * @return {Promise} */ - 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')() - } else { - Logger.debug('Entity cache is disabled for productList')() + 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 }) + + if (updateState) { + if (append) commit(types.PRODUCT_ADD_PAGED_PRODUCTS, searchResult) + else commit(types.PRODUCT_SET_PAGED_PRODUCTS, searchResult) } - if (config.entities.optimize) { - if (excludeFields === null) { // if not set explicitly we do optimize the amount of data by using some default field list; this is cacheable - excludeFields = config.entities.product.excludeFields + 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 }) } - if (includeFields === null) { // if not set explicitly we do optimize the amount of data by using some default field list; this is cacheable - includeFields = config.entities.product.includeFields + + if (isGroupedOrBundle(product) && prefetchGroupProducts && !isServer) { + context.dispatch('setupAssociated', { product }) } } - return quickSearchByQuery({ query, start, size, entityType, sort, excludeFields, includeFields }).then((resp) => { - if (resp.items && resp.items.length) { // preconfigure products; eg: after filters - for (let product of resp.items) { - if (populateRequestCacheTags && Vue.prototype.$cacheTags) { - Vue.prototype.$cacheTags.add(`P${product.id}`); - } - product.errors = {} // this is an object to store validation result for custom options and others - product.info = {} - if (!product.parentSku) { - product.parentSku = product.sku - } - if (config.products.setFirstVarianAsDefaultInURL && product.hasOwnProperty('configurable_children') && product.configurable_children.length > 0) { - product.sku = product.configurable_children[0].sku - } - if (configuration) { - let selectedVariant = configureProductAsync(context, { product: product, configuration: configuration, selectDefaultVariant: false }) - product = Object.assign({}, product, omit(selectedVariant, ['visibility'])) - } - if (product.url_path) { - rootStore.dispatch('url/registerMapping', { - url: localizedDispatcherRoute(product.url_path, currentStoreView().storeCode), - routeData: { - params: { - 'parentSku': product.parentSku, - 'slug': product.slug - }, - 'name': product.type_id + '-product' - } - }, { root: true }) - } - } + }, + preConfigureProduct (context, { product, populateRequestCacheTags, configuration }) { + let prod = preConfigureProduct({ product, populateRequestCacheTags }) + + if (configuration) { + const selectedVariant = configureProductAsync(context, { product: prod, selectDefaultVariant: false, configuration }) + prod = Object.assign({}, prod, omit(selectedVariant, ['visibility'])) + } + + return prod + }, + async configureLoadedProducts (context, { products, isCacheable, cacheByKey, populateRequestCacheTags, configuration }) { + if (products.items && products.items.length) { // preconfigure products; eg: after filters + for (let product of products.items) { + product = await context.dispatch('preConfigureProduct', { product, populateRequestCacheTags, configuration }) // preConfigure(product) } - return calculateTaxes(resp.items, context).then((updatedProducts) => { - // handle cache - const cache = Vue.prototype.$db.elasticCacheCollection - for (let prod of resp.items) { // we store each product separately in cache to have offline access to products/single method - if (prod.configurable_children) { - for (let configurableChild of prod.configurable_children) { - if (configurableChild.custom_attributes) { - for (let opt of configurableChild.custom_attributes) { - configurableChild[opt.attribute_code] = opt.value - } - } - } - } - if (!prod[cacheByKey]) { - cacheByKey = 'id' - } - const cacheKey = entityKeyName(cacheByKey, prod[(cacheByKey === 'sku' && prod['parentSku']) ? 'parentSku' : cacheByKey]) // to avoid caching products by configurable_children.sku - if (isCacheable) { // store cache only for full loads - cache.setItem(cacheKey, prod, null, config.products.disablePersistentProductsCache) - .catch((err) => { - Logger.error('Cannot store cache for ' + cacheKey, err)() - if ( - err.name === 'QuotaExceededError' || - err.name === 'NS_ERROR_DOM_QUOTA_REACHED' - ) { // quota exceeded error - cache.clear() // clear products cache if quota exceeded - } - }) - } - if ((prod.type_id === 'grouped' || prod.type_id === 'bundle') && prefetchGroupProducts && !isServer) { - context.dispatch('setupAssociated', { product: prod }) - } - } - // commit update products list mutation - if (updateState) { - context.commit(types.CATALOG_UPD_PRODUCTS, { products: resp, append: append }) - } - Vue.prototype.$bus.$emit('product-after-list', { query: query, start: start, size: size, sort: sort, entityType: entityType, meta: meta, result: resp }) - return resp - }) - }) + } + + await context.dispatch('tax/calculateTaxes', { products: products.items }, { root: true }) + + for (let prod of products.items) { // we store each product separately in cache to have offline access to products/single method + prod = configureChildren(prod) + + if (isCacheable) { // store cache only for full loads + storeProductToCache(prod, cacheByKey) + } + } + + 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 }) + return products + }, + async findConfigurableParent (context, { product, configuration }) { + const searchQuery = new SearchQuery() + const query = searchQuery.applyFilter({key: 'configurable_children.sku', value: { 'eq': product.sku }}) + 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 @@ -378,7 +371,7 @@ const actions: ActionTree = { skipCache: true }) .then(() => { context.dispatch('setCurrent', product) }) - .then(() => { Vue.prototype.$bus.$emit('product-after-setup-associated') }) + .then(() => { EventBus.$emit('product-after-setup-associated') }) }, /** @@ -407,7 +400,7 @@ const actions: ActionTree = { return new Promise((resolve, reject) => { const benchmarkTime = new Date() - const cache = Vue.prototype.$db.elasticCacheCollection + const cache = StorageManager.get('elasticCache') const setupProduct = (prod) => { // set product quantity to 1 @@ -451,7 +444,7 @@ const actions: ActionTree = { if (res && res.items && res.items.length) { let prd = res.items[0] const _returnProductNoCacheHelper = (subresults) => { - Vue.prototype.$bus.$emitFilter('product-after-single', { key: key, options: options, product: prd }) + EventBus.$emitFilter('product-after-single', { key: key, options: options, product: prd }) resolve(setupProduct(prd)) } if (setCurrentProduct || selectDefaultVariant) { @@ -487,15 +480,15 @@ const actions: ActionTree = { const cachedProduct = setupProduct(res) if (config.products.alwaysSyncPlatformPricesOver) { doPlatformPricesSync([cachedProduct]).then((products) => { - Vue.prototype.$bus.$emitFilter('product-after-single', { key: key, options: options, product: products[0] }) + EventBus.$emitFilter('product-after-single', { key: key, options: options, product: products[0] }) resolve(products[0]) }) if (!config.products.waitForPlatformSync) { - Vue.prototype.$bus.$emitFilter('product-after-single', { key: key, options: options, product: cachedProduct }) + EventBus.$emitFilter('product-after-single', { key: key, options: options, product: cachedProduct }) resolve(cachedProduct) } } else { - Vue.prototype.$bus.$emitFilter('product-after-single', { key: key, options: options, product: cachedProduct }) + EventBus.$emitFilter('product-after-single', { key: key, options: options, product: cachedProduct }) resolve(cachedProduct) } } @@ -541,13 +534,13 @@ const actions: ActionTree = { setCurrentOption (context, productOption) { if (productOption && typeof productOption === 'object') { // TODO: this causes some kind of recurrency error - context.commit(types.CATALOG_SET_PRODUCT_CURRENT, Object.assign({}, context.state.current, { product_option: productOption })) + context.commit(types.PRODUCT_SET_CURRENT, Object.assign({}, context.getters.getCurrentProduct, { product_option: productOption })) } }, setCurrentErrors (context, errors) { if (errors && typeof errors === 'object') { - context.commit(types.CATALOG_SET_PRODUCT_CURRENT, Object.assign({}, context.state.current, { errors: errors })) + context.commit(types.PRODUCT_SET_CURRENT, Object.assign({}, context.getters.getCurrentProduct, { errors: errors })) } }, /** @@ -555,7 +548,7 @@ const actions: ActionTree = { */ setCustomOptions (context, { customOptions, product }) { if (customOptions) { // TODO: this causes some kind of recurrency error - context.commit(types.CATALOG_SET_PRODUCT_CURRENT, Object.assign({}, product, { product_option: setCustomProductOptionsAsync(context, { product: context.state.current, customOptions: customOptions }) })) + context.commit(types.PRODUCT_SET_CURRENT, Object.assign({}, product, { product_option: setCustomProductOptionsAsync(context, { product: context.getters.getCurrentProduct, customOptions: customOptions }) })) } }, /** @@ -563,7 +556,7 @@ const actions: ActionTree = { */ setBundleOptions (context, { bundleOptions, product }) { if (bundleOptions) { // TODO: this causes some kind of recurrency error - context.commit(types.CATALOG_SET_PRODUCT_CURRENT, Object.assign({}, product, { product_option: setBundleProductOptionsAsync(context, { product: context.state.current, bundleOptions: bundleOptions }) })) + context.commit(types.PRODUCT_SET_CURRENT, Object.assign({}, product, { product_option: setBundleProductOptionsAsync(context, { product: context.getters.getCurrentProduct, bundleOptions: bundleOptions }) })) } }, /** @@ -574,15 +567,15 @@ const actions: ActionTree = { setCurrent (context, productVariant) { if (productVariant && typeof productVariant === 'object') { // get original product - const productOriginal = context.getters.productOriginal + const originalProduct = context.getters.getOriginalProduct // check if passed variant is the same as original - const productUpdated = Object.assign({}, productOriginal, productVariant) + const productUpdated = Object.assign({}, originalProduct, productVariant) populateProductConfigurationAsync(context, { product: productUpdated, selectedVariant: productVariant }) if (!config.products.gallery.mergeConfigurableChildren) { - context.commit(types.CATALOG_UPD_GALLERY, attributeImages(productVariant)) + context.commit(types.PRODUCT_SET_GALLERY, attributeImages(productVariant)) } - context.commit(types.CATALOG_SET_PRODUCT_CURRENT, productUpdated) + context.commit(types.PRODUCT_SET_CURRENT, Object.assign({}, productUpdated)) return productUpdated } else Logger.debug('Unable to update current product.', 'product')() }, @@ -592,76 +585,83 @@ const actions: ActionTree = { * @param {Object} originalProduct */ setOriginal (context, originalProduct) { - if (originalProduct && typeof originalProduct === 'object') context.commit(types.CATALOG_SET_PRODUCT_ORIGINAL, 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.CATALOG_UPD_RELATED, { key, 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 + * Load the product data and sets current product */ - fetch (context, { parentSku, childSku = null }) { + async loadProduct ({ dispatch }, { parentSku, childSku = null, route = null }) { + 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 } - return context.dispatch('single', { options: productSingleOptions }).then((product) => { - if (product.status >= 2) { - throw new Error(`Product query returned empty result product status = ${product.status}`) - } - if (product.visibility === 1) { // not visible individually (https://magento.stackexchange.com/questions/171584/magento-2-table-name-for-product-visibility) - throw new Error(`Product query returned empty result product visibility = ${product.visibility}`) - } - - let subloaders = [] - if (product) { - const productFields = Object.keys(product).filter(fieldName => { - return config.entities.product.standardSystemFields.indexOf(fieldName) < 0 // don't load metadata info for standard fields - }) - const attributesPromise = context.dispatch('attribute/list', { // load attributes to be shown on the product details - the request is now async - filterValues: config.entities.product.useDynamicAttributeLoader ? productFields : null, - only_visible: config.entities.product.useDynamicAttributeLoader === true, - only_user_defined: true, - includeFields: config.entities.optimize ? config.entities.attribute.includeFields : null - }, { root: true }) // TODO: it might be refactored to kind of: `await context.dispatch('attributes/list) - or using new Promise() .. to wait for attributes to be loaded before executing the next action. However it may decrease the performance - so for now we're just waiting with the breadcrumbs - if (isServer) { - subloaders.push(context.dispatch('setupBreadcrumbs', { product: product })) - subloaders.push(context.dispatch('filterUnavailableVariants', { product: product })) - } else { - attributesPromise.then(() => context.dispatch('setupBreadcrumbs', { product: product })) // if this is client's side request postpone breadcrumbs setup till attributes are loaded to avoid too-early breadcrumb switch #2469 - context.dispatch('filterUnavailableVariants', { product: product }) // exec async - } - subloaders.push(attributesPromise) - - // subloaders.push(context.dispatch('setupVariants', { product: product })) -- moved to "product/single" - /* if (product.type_id === 'grouped' || product.type_id === 'bundle') { -- moved to "product/single" - subloaders.push(context.dispatch('setupAssociated', { product: product }).then((subloaderresults) => { - context.dispatch('setCurrent', product) // because setup Associated can modify the product price we need to update the current product - })) - } */ - - context.dispatch('setProductGallery', { product: product }) - - if (config.products.preventConfigurableChildrenDirectAccess) { - subloaders.push(context.dispatch('checkConfigurableParent', { product: product })) - } - } else { // error or redirect + const product = await dispatch('single', { options: productSingleOptions }) + if (product.status >= 2) { + throw new Error(`Product query returned empty result product status = ${product.status}`) + } + if (product.visibility === 1) { // not visible individually (https://magento.stackexchange.com/questions/171584/magento-2-table-name-for-product-visibility) + throw new Error(`Product query returned empty result product visibility = ${product.visibility}`) + } + await dispatch('loadProductAttributes', { product }) + const syncPromises = [] + const variantsFilter = dispatch('filterUnavailableVariants', { product }) + const gallerySetup = dispatch('setProductGallery', { product }) + if (isServer) { + syncPromises.push(variantsFilter) + syncPromises.push(gallerySetup) + } + if (config.products.preventConfigurableChildrenDirectAccess) { + const parentChecker = dispatch('checkConfigurableParent', { product }) + if (isServer) { + syncPromises.push(parentChecker) } - return subloaders - }) + } + await Promise.all(syncPromises) + await EventBus.$emitFilter('product-after-load', { store: rootStore, route: route }) + return product }, /** * Add custom option validator for product custom options */ addCustomOptionValidator (context, { validationRule, validatorFunction }) { - context.commit(types.CATALOG_ADD_CUSTOM_OPTION_VALIDATOR, { validationRule, validatorFunction }) + context.commit(types.PRODUCT_SET_CUSTOM_OPTION_VALIDATOR, { validationRule, validatorFunction }) }, /** @@ -671,54 +671,33 @@ 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.CATALOG_UPD_GALLERY, attributeImages(context.state.current)) + 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.CATALOG_UPD_GALLERY, productGallery) + 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.CATALOG_UPD_GALLERY, productGallery) + context.commit(types.PRODUCT_SET_GALLERY, productGallery) } }, - - /** - * Load the product data - async version for asyncData() - */ - fetchAsync (context, { parentSku, childSku = null, route = null }) { - if (context.state.productLoadStart && (Date.now() - context.state.productLoadStart) < PRODUCT_REENTER_TIMEOUT) { - Logger.log('Product is being fetched ...', 'product')() - } else { - context.state.productLoadPromise = new Promise((resolve, reject) => { - context.state.productLoadStart = Date.now() - Logger.info('Fetching product data asynchronously', 'product', {parentSku, childSku})() - Vue.prototype.$bus.$emit('product-before-load', { store: rootStore, route: route }) - context.dispatch('reset').then(() => { - context.dispatch('fetch', { parentSku: parentSku, childSku: childSku }).then((subpromises) => { - Promise.all(subpromises).then(subresults => { - Vue.prototype.$bus.$emitFilter('product-after-load', { store: rootStore, route: route }).then((results) => { - context.state.productLoadStart = null - return resolve() - }).catch((err) => { - context.state.productLoadStart = null - Logger.error(err, 'product')() - return resolve() - }) - }).catch(errs => { - context.state.productLoadStart = null - reject(errs) - }) - }).catch(err => { - context.state.productLoadStart = null - reject(err) - }).catch(err => { - context.state.productLoadStart = null - reject(err) - }) - }) - }) + async loadProductBreadcrumbs ({ dispatch, rootGetters }, { product } = {}) { + if (product && product.category_ids) { + const currentCategory = rootGetters['category-next/getCurrentCategory'] + let breadcrumbCategory + const categoryFilters = Object.assign({ 'id': [...product.category_ids] }, cloneDeep(config.entities.category.breadcrumbFilterFields)) + const categories = await dispatch('category-next/loadCategories', { filters: categoryFilters, reloadAll: Object.keys(config.entities.category.breadcrumbFilterFields).length > 0 }, { root: true }) + if ( + (currentCategory && currentCategory.id) && // current category exist + (config.entities.category.categoriesRootCategorylId !== currentCategory.id) && // is not highest category (All) - if we open product from different page then category page + (categories.findIndex(category => category.id === currentCategory.id) >= 0) // can be found in fetched categories + ) { + breadcrumbCategory = currentCategory // use current category if set and included in the filtered list + } else { + breadcrumbCategory = categories.sort((a, b) => (a.level > b.level) ? -1 : 1)[0] // sort starting by deepest level + } + await dispatch('category-next/loadCategoryBreadcrumbs', { category: breadcrumbCategory, currentRouteName: product.name }, { root: true }) } - return context.state.productLoadPromise } } diff --git a/core/modules/catalog/store/product/getters.ts b/core/modules/catalog/store/product/getters.ts index 57bc655c1..cc4431a7c 100644 --- a/core/modules/catalog/store/product/getters.ts +++ b/core/modules/catalog/store/product/getters.ts @@ -1,29 +1,17 @@ -import { PagedProductList } from './../../types/ProductState'; -import { nonReactiveState } from './index'; import { GetterTree } from 'vuex' import RootState from '@vue-storefront/core/types/RootState' import ProductState from '../../types/ProductState' -import cloneDeep from 'lodash-es/cloneDeep' - -function mapCategoryProducts (productsFromState, productsData) { - return productsFromState.map(prodState => { - if (typeof prodState === 'string') { - const product = productsData.find(prodData => prodData.sku === prodState) - return cloneDeep(product) - } - return prodState - }) -} const getters: GetterTree = { - productParent: (state) => state.parent, - productCurrent: (state) => state.current, - currentConfiguration: (state) => state.current_configuration, - productOriginal: (state) => state.original, - currentOptions: (state) => state.current_options, - breadcrumbs: (state) => state.breadcrumbs, - productGallery: (state) => state.productGallery, - list: (state) => mapCategoryProducts((state.list as PagedProductList).items, nonReactiveState.list) + getCurrentProduct: state => state.current, + getCurrentProductConfiguration: state => state.current_configuration, + getCurrentProductOptions: state => state.current_options, + getOriginalProduct: state => state.original, + getParentProduct: state => state.parent, + getProductsSearchResult: state => state.list, + getProducts: (state, getters) => getters.getProductsSearchResult.items, + getProductGallery: state => state.productGallery, + getProductRelated: state => state.related } export default getters diff --git a/core/modules/catalog/store/product/index.ts b/core/modules/catalog/store/product/index.ts index 81b1dfc5b..4928c277f 100644 --- a/core/modules/catalog/store/product/index.ts +++ b/core/modules/catalog/store/product/index.ts @@ -8,6 +8,7 @@ import ProductState from '../../types/ProductState' export const productModule: Module = { namespaced: true, state: { + // TODO use breadcrumbs from category-next, leave here for backward compatibility breadcrumbs: { routes: [], name: '' @@ -19,7 +20,12 @@ export const productModule: Module = { }, current_configuration: {}, parent: null, - list: [], + list: { + start: 0, + perPage: 50, + total: 0, + items: [] + }, original: null, // default, not configured product related: {}, offlineImage: null, diff --git a/core/modules/catalog/store/product/mutation-types.ts b/core/modules/catalog/store/product/mutation-types.ts index 91e34b38d..8b868541f 100644 --- a/core/modules/catalog/store/product/mutation-types.ts +++ b/core/modules/catalog/store/product/mutation-types.ts @@ -1,13 +1,26 @@ export const SN_PRODUCT = 'product' +export const PRODUCT_SET_PAGED_PRODUCTS = SN_PRODUCT + '/SET_PRODUCTS' +export const PRODUCT_ADD_PAGED_PRODUCTS = SN_PRODUCT + '/ADD_PRODUCTS' +export const PRODUCT_SET_RELATED = SN_PRODUCT + '/SET_RELATED' +export const PRODUCT_SET_CURRENT = SN_PRODUCT + '/SET_CURRENT' +export const PRODUCT_SET_CURRENT_OPTIONS = SN_PRODUCT + '/SET_CURRENT_OPTIONS' +export const PRODUCT_RESET_CURRENT = SN_PRODUCT + '/RESET_CURRENT' +export const PRODUCT_SET_ORIGINAL = SN_PRODUCT + '/SET_ORIGINAL' +export const PRODUCT_SET_CURRENT_CONFIGURATION = SN_PRODUCT + '/SET_CURRENT_CONFIGURATION' +export const PRODUCT_SET_PARENT = SN_PRODUCT + '/SET_PARENT' +export const PRODUCT_SET_CUSTOM_OPTION = SN_PRODUCT + '/SET_CUSTOM_OPTION' +export const PRODUCT_SET_CUSTOM_OPTION_VALIDATOR = SN_PRODUCT + '/SET_CUSTOM_OPTION_VALIDATOR' +export const PRODUCT_SET_BUNDLE_OPTION = SN_PRODUCT + '/SET_BUNDLE_OPTION' +export const PRODUCT_SET_GALLERY = SN_PRODUCT + '/SET_PRODUCT_GALLERY' +// remove later export const CATALOG_UPD_PRODUCTS = SN_PRODUCT + '/UPD_PRODUCTS' export const CATALOG_UPD_RELATED = SN_PRODUCT + '/UPD_RELATED' -export const CATALOG_UPD_SEARCH_QUERY = SN_PRODUCT + '/UPD_SEARCH_QUERY' export const CATALOG_SET_PRODUCT_CURRENT = SN_PRODUCT + '/SET_PRODUCT_CURRENT' export const CATALOG_SET_PRODUCT_ORIGINAL = SN_PRODUCT + '/SET_PRODUCT_ORIGINAL' export const CATALOG_RESET_PRODUCT = SN_PRODUCT + '/RESET_PRODUCT_ORIGINAL' export const CATALOG_SET_PRODUCT_PARENT = SN_PRODUCT + '/SET_PARENT' export const CATALOG_UPD_CUSTOM_OPTION = SN_PRODUCT + '/SET_CUSTOM_OPTION' -export const CATALOG_UPD_BUNDLE_OPTION = SN_PRODUCT + '/SET_BUNDLE_OPTION' +export const CATALOG_UPD_BUNDLE_OPTION = SN_PRODUCT + '/UPD_BUNDLE_OPTION' export const CATALOG_ADD_CUSTOM_OPTION_VALIDATOR = SN_PRODUCT + '/ADD_CUSTOM_OPTION_VALIDATOR' export const CATALOG_UPD_GALLERY = SN_PRODUCT + '/SET_GALLERY' export const CATALOG_SET_BREADCRUMBS = SN_PRODUCT + '/SET_BREADCRUMBS' diff --git a/core/modules/catalog/store/product/mutations.ts b/core/modules/catalog/store/product/mutations.ts index bc85cf289..5a4abe7f5 100644 --- a/core/modules/catalog/store/product/mutations.ts +++ b/core/modules/catalog/store/product/mutations.ts @@ -1,72 +1,118 @@ -import { isServer } from '@vue-storefront/core/helpers'; -import { nonReactiveState } from './index'; -import Vue from 'vue' import { MutationTree } from 'vuex' import * as types from './mutation-types' import ProductState, { PagedProductList } from '../../types/ProductState' -import cloneDeep from 'lodash-es/cloneDeep' const mutations: MutationTree = { - [types.CATALOG_SET_BREADCRUMBS] (state, payload) { - state.breadcrumbs = payload + [types.PRODUCT_SET_PAGED_PRODUCTS] (state, searchResult) { + const { start, perPage, total, items } = searchResult + state.list = { + start, + perPage, + total, + items + } }, - [types.CATALOG_UPD_RELATED] (state, { key, items }) { - state.related[key] = items - Vue.prototype.$bus.$emit('product-after-related', { key: key, items: items }) + [types.PRODUCT_ADD_PAGED_PRODUCTS] (state, searchResult) { + const { start, perPage, items } = searchResult + state.list = Object.assign( + {}, + state.list, + { + start, + perPage, + items: [...(state.list as PagedProductList).items, ...items] + } + ) }, - [types.CATALOG_ADD_CUSTOM_OPTION_VALIDATOR] (state, { validationRule, validatorFunction }) { - state.custom_options_validators[validationRule] = validatorFunction + [types.PRODUCT_SET_RELATED] (state, { key, items }) { + state.related = Object.assign( + {}, + state.related, + {[key]: items} + ) }, - [types.CATALOG_UPD_CUSTOM_OPTION] (state, { optionId, optionValue }) { - state.current_custom_options[optionId] = { - option_id: optionId, - option_value: optionValue - } + [types.PRODUCT_SET_CURRENT] (state, product) { + state.current = product }, - [types.CATALOG_UPD_BUNDLE_OPTION] (state, { optionId, optionQty, optionSelections }) { - state.current_bundle_options[optionId] = { + [types.PRODUCT_RESET_CURRENT] (state, originalProduct) { + state.current = Object.assign({}, originalProduct) + state.current_configuration = {} + state.offlineImage = null + state.parent = null + state.current_options = {color: [], size: []} + state.current_bundle_options = {} + state.current_custom_options = {} + }, + [types.PRODUCT_SET_CURRENT_OPTIONS] (state, configuration = {}) { + state.current_options = configuration + }, + [types.PRODUCT_SET_CURRENT_CONFIGURATION] (state, configuration = {}) { + state.current_configuration = configuration + }, + [types.PRODUCT_SET_ORIGINAL] (state, product) { + state.original = product + }, + [types.PRODUCT_SET_PARENT] (state, product) { + state.parent = product + }, + [types.PRODUCT_SET_CUSTOM_OPTION] (state, { optionId, optionValue }) { + state.current_custom_options = Object.assign( + {}, + state.current_custom_options, + {[optionId]: { + option_id: optionId, + option_value: optionValue + }} + ) + }, + [types.PRODUCT_SET_BUNDLE_OPTION] (state, { optionId, optionQty, optionSelections }) { + const option = { option_id: optionId, option_qty: optionQty, option_selections: optionSelections } + state.current_bundle_options = Object.assign( + {}, + state.current_bundle_options, + {[optionId]: option} + ) + }, + [types.PRODUCT_SET_CUSTOM_OPTION_VALIDATOR] (state, { validationRule, validatorFunction }) { + state.custom_options_validators = Object.assign( + {}, + state.custom_options_validators, + {[validationRule]: validatorFunction} + ) + }, + [types.PRODUCT_SET_GALLERY] (state, productGallery) { + state.productGallery = productGallery + }, + [types.CATALOG_SET_BREADCRUMBS] (state, payload) { + 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') + }, + [types.CATALOG_UPD_RELATED] (state, { key, items }) { + console.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') }, [types.CATALOG_UPD_PRODUCTS] (state, { products, append }) { - if (append === false) { - nonReactiveState.list = cloneDeep(products.items) - state.list = isServer ? products : cloneDeep({...products, items: products.items.map(prod => prod.sku)}) - } else { - const pagedProductList = state.list as PagedProductList - pagedProductList.start = products.start as number - pagedProductList.perPage = products.perPage as number - nonReactiveState.list = cloneDeep(nonReactiveState.list.concat(products.items)) - pagedProductList.items = isServer - ? pagedProductList.items.concat(products.items) - : cloneDeep(pagedProductList.items.concat(products.items.map(prod => prod.sku))) - } + console.error('Deprecated mutation CATALOG_UPD_PRODUCTS - use PRODUCT_SET_PAGED_PRODUCTS or PRODUCT_ADD_PAGED_PRODUCTS instead') }, [types.CATALOG_SET_PRODUCT_CURRENT] (state, product) { - state.current = product + console.error('Deprecated mutation CATALOG_SET_PRODUCT_CURRENT - use PRODUCT_SET_CURRENT instead') }, [types.CATALOG_SET_PRODUCT_ORIGINAL] (state, product) { - state.original = product - Vue.prototype.$bus.$emit('product-after-original', { original: product }) - }, - [types.CATALOG_SET_PRODUCT_PARENT] (state, product) { - state.parent = product - Vue.prototype.$bus.$emit('product-after-parent', { parent: product }) + console.error('Deprecated mutation CATALOG_SET_PRODUCT_ORIGINAL - use PRODUCT_SET_ORIGINAL instead') }, [types.CATALOG_RESET_PRODUCT] (state, productOriginal) { - state.current = productOriginal || {} - state.current_configuration = {} - state.offlineImage = null - state.parent = null - state.current_options = {color: [], size: []} - state.current_bundle_options = {} - state.current_custom_options = {} - Vue.prototype.$bus.$emit('product-after-reset', { }) + console.error('Deprecated mutation CATALOG_RESET_PRODUCT - use PRODUCT_RESET_CURRENT instead') }, [types.CATALOG_UPD_GALLERY] (state, productGallery) { - state.productGallery = productGallery + console.error('Deprecated mutation CATALOG_UPD_GALLERY - use PRODUCT_SET_GALLERY instead') } } diff --git a/core/modules/catalog/store/stock/actions.ts b/core/modules/catalog/store/stock/actions.ts index 1be430864..85ba6cf5a 100644 --- a/core/modules/catalog/store/stock/actions.ts +++ b/core/modules/catalog/store/stock/actions.ts @@ -1,105 +1,71 @@ -import Vue from 'vue' import { ActionTree } from 'vuex' -import i18n from '@vue-storefront/i18n' -// requires cart module -import * as types from '@vue-storefront/core/modules/cart/store/mutation-types' +import * as stockMutationTypes from '@vue-storefront/core/modules/catalog/store/stock/mutation-types' import RootState from '@vue-storefront/core/types/RootState' import StockState from '../../types/StockState' -import { TaskQueue } from '@vue-storefront/core/lib/sync' -import { Logger } from '@vue-storefront/core/lib/logger' import config from 'config' -import { processURLAddress } from '@vue-storefront/core/helpers' +import { StockService } from '@vue-storefront/core/data-resolver' +import { getStatus, getProductInfos } from '@vue-storefront/core/modules/catalog/helpers/stock' +import { Logger } from '@vue-storefront/core/lib/logger' const actions: ActionTree = { - /** - * Reset current configuration and selected variatnts - */ - async queueCheck (context, { product, qty = 1 }) { + async queueCheck ({ dispatch }, { product }) { + const checkStatus = { + qty: product.stock ? product.stock.qty : 0, + status: getStatus(product, 'ok') + } + if (config.stock.synchronize) { - const task: any = await TaskQueue.queue({ url: processURLAddress(`${config.stock.endpoint}/check?sku=${encodeURIComponent(product.sku)}`), - payload: { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - mode: 'cors' - }, - is_result_cacheable: true, // store result for the Checkout.js double check - product_sku: product.sku, - callback_event: 'store:stock/stockAfterCheck' - }) - return { qty: product.stock ? product.stock.qty : 0, status: product.stock ? (product.stock.is_in_stock ? 'ok' : 'out_of_stock') : 'ok', onlineCheckTaskId: task.task_id } // if online we can return ok because it will be verified anyway - } else { - return { qty: product.stock ? product.stock.qty : 0, status: product.stock ? (product.stock.is_in_stock ? 'ok' : 'out_of_stock') : 'volatile' } // if not online, cannot check the source of true here + const task = await StockService.queueCheck(product.sku, 'cart/stockSync') + + // @ts-ignore + Logger.debug(`Stock quantity checked for ${task.product_sku}, response time: ${task.transmited_at - task.created_at} ms`, 'stock')() + + return { + ...checkStatus, + onlineCheckTaskId: task.task_id + } + } + + return { + ...checkStatus, + status: getStatus(product, 'volatile') } }, - /** - * Reset current configuration and selected variatnts - */ - async check (context, { product, qty = 1 }) { + async check (context, { product }) { if (config.stock.synchronize) { - const task: any = TaskQueue.execute({ url: processURLAddress(`${config.stock.endpoint}/check?sku=${encodeURIComponent(product.sku)}`), - payload: { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - mode: 'cors' - }, - product_sku: product.sku - }) - return { qty: task.result ? task.result.qty : 0, status: task.result ? (task.result.is_in_stock ? 'ok' : 'out_of_stock') : 'ok', onlineCheckTaskId: task.task_id } // if online we can return ok because it will be verified anyway - } else { - return { qty: product.stock ? product.stock.qty : 0, status: product.stock ? (product.stock.is_in_stock ? 'ok' : 'out_of_stock') : 'volatile' } // if not online, cannot check the source of true here + const { result, task_id } = await StockService.check(product.sku) + return { + qty: result ? result.qty : 0, + status: getStatus(result, 'ok'), + onlineCheckTaskId: task_id + } + } + + return { + qty: product.stock ? product.stock.qty : 0, + status: getStatus(product, 'volatile') } }, - /** - * Reset current configuration and selected variatnts - */ - list (context, { skus }) { - if (config.stock.synchronize) { - try { - const task: any = TaskQueue.execute({ url: processURLAddress(`${config.stock.endpoint}/list?skus=${encodeURIComponent(skus.join(','))}`), - payload: { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - mode: 'cors' - }, - skus: skus + async list ({ commit }, { skus }) { + if (!config.stock.synchronize) return + + const task = await StockService.list(skus) + + if (task.resultCode === 200) { + const productInfos = getProductInfos(task.result) + + for (const productInfo of productInfos) { + commit(stockMutationTypes.SET_STOCK_CACHE_PRODUCT, { + productId: productInfo.product_id, + productInfo }) - if (task.resultCode === 200) { - for (const si of task.result) { - context.state.cache[si.product_id] = { is_in_stock: si.is_in_stock, qty: si.qty, product_id: si.product_id } // TODO: should be moved to mutation - } - } - return task // if online we can return ok because it will be verified anyway - } catch (err) { - Logger.error(err, 'stock')() - return null } - } else { - return null // if not online, cannot check the source of true here } + + return task }, - clearCache (context) { - context.state.cache = {} - }, - async stockAfterCheck (context, event) { - setTimeout(async () => { - // TODO: Move to cart module - const cartItem: any = await context.dispatch('cart/getItem', event.product_sku, { root: true }) - if (cartItem && event.result.code !== 'ENOTFOUND') { - if (!event.result.is_in_stock) { - if (!config.stock.allowOutOfStockInCart && !config.cart.synchronize) { // if config.cart.synchronize is true then - the items are being removed by the result of cart/update action executed from cart/sync - Logger.log('Removing product from cart' + event.product_sku, 'stock')() - context.commit('cart/' + types.CART_DEL_ITEM, { product: { sku: event.product_sku } }, {root: true}) - } else { - context.dispatch('cart/updateItem', { product: { errors: { stock: i18n.t('Out of the stock!') }, sku: event.product_sku, is_in_stock: false } }, { root: true }) - } - } else { - context.dispatch('cart/updateItem', { product: { info: { stock: i18n.t('In stock!') }, sku: event.product_sku, is_in_stock: true } }, { root: true }) - } - Vue.prototype.$bus.$emit('cart-after-itemchanged', { item: cartItem }) - } - Logger.debug('Stock quantity checked for ' + event.result.product_id + ', response time: ' + (event.transmited_at - event.created_at) + ' ms', 'stock')() - Logger.debug(event, 'stock')() - }, 500) + clearCache ({ commit }) { + commit(stockMutationTypes.SET_STOCK_CACHE, {}) } } diff --git a/core/modules/catalog/store/stock/index.ts b/core/modules/catalog/store/stock/index.ts index 4e4f00aa4..264dd31fc 100644 --- a/core/modules/catalog/store/stock/index.ts +++ b/core/modules/catalog/store/stock/index.ts @@ -1,11 +1,13 @@ import { Module } from 'vuex' import actions from './actions' +import mutations from './mutations' import RootState from '@vue-storefront/core/types/RootState' import StockState from '../../types/StockState' export const stockModule: Module = { namespaced: true, actions, + mutations, state: { cache: {} } diff --git a/core/modules/catalog/store/stock/mutation-types.ts b/core/modules/catalog/store/stock/mutation-types.ts new file mode 100644 index 000000000..688b20106 --- /dev/null +++ b/core/modules/catalog/store/stock/mutation-types.ts @@ -0,0 +1,3 @@ +export const SN_CATALOG = 'catalog' +export const SET_STOCK_CACHE = `${SN_CATALOG}/SET_STOCK_CACHE` +export const SET_STOCK_CACHE_PRODUCT = `${SN_CATALOG}/SET_STOCK_CACHE_PRODUCT` diff --git a/core/modules/catalog/store/stock/mutations.ts b/core/modules/catalog/store/stock/mutations.ts new file mode 100644 index 000000000..b684e2599 --- /dev/null +++ b/core/modules/catalog/store/stock/mutations.ts @@ -0,0 +1,16 @@ +import { MutationTree } from 'vuex' +import StockState from '../../types/StockState' +import * as types from './mutation-types' + +const mutations: MutationTree = { + [types.SET_STOCK_CACHE] (state, cache) { + state.cache = cache + }, + [types.SET_STOCK_CACHE_PRODUCT] (state, { productId, productInfo }) { + state.cache = Object.assign({}, state.cache, { + [productId]: productInfo + }) + } +} + +export default mutations diff --git a/core/modules/catalog/store/tax/actions.ts b/core/modules/catalog/store/tax/actions.ts index c304cc6a1..8847c2fa5 100644 --- a/core/modules/catalog/store/tax/actions.ts +++ b/core/modules/catalog/store/tax/actions.ts @@ -5,27 +5,73 @@ import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' import RootState from '@vue-storefront/core/types/RootState' import TaxState from '../../types/TaxState' import { Logger } from '@vue-storefront/core/lib/logger' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' +import { entityKeyName } from '@vue-storefront/core/lib/store/entities' +import config from 'config' +import { calculateProductTax } from '@vue-storefront/core/modules/catalog/helpers/taxCalc' +import { doPlatformPricesSync } from '@vue-storefront/core/modules/catalog/helpers' +import { catalogHooksExecutors } from './../../hooks' const actions: ActionTree = { - /** - * Load the tax rules - */ - list (context, { entityType = 'taxrule' }) { - if (context.state.rules.length > 0) { + async list ({ state, commit, dispatch }, { entityType = 'taxrule' }) { + if (state.rules.length > 0) { Logger.info('Tax rules served from local memory', 'tax')() - return new Promise((resolve, reject) => { - resolve({ items: context.state.rules }) - }) - } else { - const searchQuery = new SearchQuery() - return quickSearchByQuery({ query: searchQuery, entityType }).then((resp) => { - context.commit(types.TAX_UPDATE_RULES, resp) - return resp + return { items: state.rules } + } + + const resp = await quickSearchByQuery({ query: new SearchQuery(), entityType }) + dispatch('storeToRulesCache', { items: resp.items }) + commit(types.TAX_UPDATE_RULES, resp) + + return resp + }, + storeToRulesCache (context, { items }) { + const cache = StorageManager.get('elasticCache') + + for (let tc of items) { + const cacheKey = entityKeyName('tc', tc.id) + cache.setItem(cacheKey, tc).catch((err) => { + Logger.error('Cannot store cache for ' + cacheKey + ', ' + err)() }) } }, - single (context, { productTaxClassId }) { - return context.state.rules.find((e) => { return e.product_tax_class_ids.indexOf(parseInt(productTaxClassId)) >= 0 }) + single ({ getters }, { productTaxClassId }) { + return getters.getRules.find((e) => + e.product_tax_class_ids.indexOf(parseInt(productTaxClassId)) >= 0 + ) + }, + async calculateTaxes ({ dispatch, getters, rootState }, { products }) { + const mutatedProducts = catalogHooksExecutors.beforeTaxesCalculated(products) + + if (config.tax.calculateServerSide) { + Logger.debug('Taxes calculated server side, skipping')() + return doPlatformPricesSync(mutatedProducts) + } + + const tcs = await dispatch('list', {}) + const { + defaultCountry, + defaultRegion, + sourcePriceIncludesTax, + finalPriceIncludesTax, + deprecatedPriceFieldsSupport + } = rootState.storeView.tax + + const recalculatedProducts = mutatedProducts.map(product => + calculateProductTax({ + product, + taxClasses: tcs.items, + taxCountry: defaultCountry, + taxRegion: defaultRegion, + finalPriceInclTax: finalPriceIncludesTax, + sourcePriceInclTax: sourcePriceIncludesTax, + userGroupId: getters.getUserTaxGroupId, + deprecatedPriceFieldsSupport: deprecatedPriceFieldsSupport, + isTaxWithUserGroupIsActive: getters.getIsUserGroupedTaxActive + }) + ) + + return doPlatformPricesSync(recalculatedProducts) } } diff --git a/core/modules/catalog/store/tax/getters.ts b/core/modules/catalog/store/tax/getters.ts new file mode 100644 index 000000000..1fc26780a --- /dev/null +++ b/core/modules/catalog/store/tax/getters.ts @@ -0,0 +1,24 @@ +import { GetterTree } from 'vuex' +import RootState from '@vue-storefront/core/types/RootState' +import TaxState from '../../types/TaxState' + +const getters: GetterTree = { + getRules: (state) => state.rules, + getUserTaxGroupId: (state, getters, rootState) => { + if (!getters.getIsUserGroupedTaxActive) return + + const storeViewTax = rootState.storeView.tax + const currentUser = rootState.user.current + + if (storeViewTax.useOnlyDefaultUserGroupId || !currentUser) { + return storeViewTax.userGroupId + } + + return currentUser.group_id + }, + getIsUserGroupedTaxActive: (state, getters, rootState) => { + return typeof rootState.storeView.tax.userGroupId === 'number' + } +} + +export default getters diff --git a/core/modules/catalog/store/tax/index.ts b/core/modules/catalog/store/tax/index.ts index 770d17ffc..c57a55eba 100644 --- a/core/modules/catalog/store/tax/index.ts +++ b/core/modules/catalog/store/tax/index.ts @@ -1,6 +1,7 @@ import { Module } from 'vuex' import actions from './actions' import mutations from './mutations' +import getters from './getters' import RootState from '@vue-storefront/core/types/RootState' import TaxState from '../../types/TaxState' @@ -10,5 +11,6 @@ export const taxModule: Module = { rules: [] }, actions, - mutations + mutations, + getters } diff --git a/core/modules/catalog/store/tax/mutations.ts b/core/modules/catalog/store/tax/mutations.ts index 78d0d8b4a..e731d955a 100644 --- a/core/modules/catalog/store/tax/mutations.ts +++ b/core/modules/catalog/store/tax/mutations.ts @@ -1,20 +1,10 @@ -import Vue from 'vue' import { MutationTree } from 'vuex' import * as types from './mutation-types' -import { entityKeyName } from '@vue-storefront/core/store/lib/entities' import TaxState from '../../types/TaxState' -import { Logger } from '@vue-storefront/core/lib/logger' const mutations: MutationTree = { [types.TAX_UPDATE_RULES] (state, taxClasses) { - const cache = Vue.prototype.$db.elasticCacheCollection - for (let tc of taxClasses.items) { // we store each product separately in cache to have offline acces for products/single method - const cacheKey = entityKeyName('tc', tc.id) - cache.setItem(cacheKey, tc).catch((err) => { - Logger.error('Cannot store cache for ' + cacheKey + ', ' + err)() - }) - } - state.rules = taxClasses.items // extract fields from ES _source + state.rules = taxClasses.items } } diff --git a/core/modules/catalog/test/unit/helpers/filters.spec.ts b/core/modules/catalog/test/unit/helpers/filters.spec.ts new file mode 100644 index 000000000..7a4f54211 --- /dev/null +++ b/core/modules/catalog/test/unit/helpers/filters.spec.ts @@ -0,0 +1,79 @@ +import Product from '@vue-storefront/core/modules/catalog/types/Product'; +import { ProductConfiguration } from '@vue-storefront/core/modules/catalog/types/ProductConfiguration'; +import { + getAvailableFiltersByProduct, + getSelectedFiltersByProduct +} from '@vue-storefront/core/modules/catalog/helpers/filters'; + +const product = ({ + configurable_options: [ + { + attribute_id: '93', + values: [ + { value_index: 50, label: 'Blue' }, + { value_index: 52, label: 'Gray' }, + { value_index: 58, label: 'Red' } + ], + product_id: 755, + id: 101, + label: 'Color', + position: 1, + attribute_code: 'color' + }, + { + attribute_id: '142', + values: [ + { value_index: 176, label: '32' }, + { value_index: 177, label: '33' }, + { value_index: 178, label: '34' }, + { value_index: 179, label: '36' } + ], + product_id: 755, + id: 100, + label: 'Size', + position: 0, + attribute_code: 'size' + } + ] +} as any) as Product; + +describe('Product configuration', () => { + it('returns available filters based on given product', () => { + const availableFilters = getAvailableFiltersByProduct(product) + + expect(availableFilters).toEqual({ + color: [ + { id: 50, label: 'Blue', type: 'color' }, + { id: 52, label: 'Gray', type: 'color' }, + { id: 58, label: 'Red', type: 'color' } + ], + size: [ + { id: 176, label: '32', type: 'size' }, + { id: 177, label: '33', type: 'size' }, + { id: 178, label: '34', type: 'size' }, + { id: 179, label: '36', type: 'size' } + ] + }); + }); + + it('returns selected filters based on given product and current configuration', () => { + const configuration: ProductConfiguration = { + color: { + attribute_code: 'color', + id: 52, + label: 'Gray' + }, + size: { + attribute_code: 'size', + id: 177, + label: '33' + } + } + const selectedFilters = getSelectedFiltersByProduct(product, configuration) + + expect(selectedFilters).toEqual({ + color: { id: 52, label: 'Gray', type: 'color' }, + size: { id: 177, label: '33', type: 'size' } + }) + }); +}); diff --git a/core/modules/catalog/types/Attribute.ts b/core/modules/catalog/types/Attribute.ts new file mode 100644 index 000000000..0a4851ecc --- /dev/null +++ b/core/modules/catalog/types/Attribute.ts @@ -0,0 +1,4 @@ +export default interface Attribute { + attribute_code?: string, + attribute_id?: number | string +} diff --git a/core/modules/catalog/types/CustomOption.ts b/core/modules/catalog/types/CustomOption.ts new file mode 100644 index 000000000..d9c6c9a39 --- /dev/null +++ b/core/modules/catalog/types/CustomOption.ts @@ -0,0 +1,24 @@ +export interface CustomOption { + image_size_x: number, + image_size_y: number, + is_require: boolean, + max_characters: number, + option_id: number, + product_sku: string, + sort_order: number, + title: string, + type: string, + price?: number, + price_type?: string, + values?: OptionValue[] +} + +export interface OptionValue { + option_type_id: number, + price: number, + price_type: string, + sort_order: number, + title: string +} + +export type InputValue = string | number | number[] diff --git a/core/modules/catalog/types/Product.ts b/core/modules/catalog/types/Product.ts index 356afbfe0..70cda94e3 100644 --- a/core/modules/catalog/types/Product.ts +++ b/core/modules/catalog/types/Product.ts @@ -2,7 +2,7 @@ export default interface Product { category: Record[], category_ids: string[], color: string, - color_options?: number[], + color_options?: number[] | string[], configurable_children: Record[], configurable_options: Record[], custom_attributes?: any, @@ -11,7 +11,7 @@ export default interface Product { final_price: number, gift_message_available: string, has_options?: string, - id?: number, + id?: number | string, image: string, info?: Record, is_configured?: true, @@ -27,8 +27,8 @@ export default interface Product { parentSku?: string, pattern?: string, price: number, - priceInclTax?: number, - priceTax?: number, + price_incl_tax?: number, + price_tax?: number, product_links?: Record[], product_option?: Record, regular_price: number, @@ -36,12 +36,12 @@ export default interface Product { sale?: string, sgn?: string, size: string, - size_options?: number[], + size_options?: number[] | string[], sku: string, slug?: string, small_image?: string, - specialPriceInclTax?: any, - specialPriceTax?: any, + special_price_incl_tax?: any, + special_price_tax?: any, special_price?: number, status: number, stock: Record, diff --git a/core/modules/catalog/types/ProductConfiguration.ts b/core/modules/catalog/types/ProductConfiguration.ts new file mode 100644 index 000000000..b5965b54b --- /dev/null +++ b/core/modules/catalog/types/ProductConfiguration.ts @@ -0,0 +1,10 @@ +export interface ProductOption { + attribute_code?: string, + id: number | string, + label: string +} + +export interface ProductConfiguration { + color: ProductOption, + size: ProductOption +} diff --git a/core/modules/catalog/types/ProductState.ts b/core/modules/catalog/types/ProductState.ts index c157dfdb3..128798e3a 100644 --- a/core/modules/catalog/types/ProductState.ts +++ b/core/modules/catalog/types/ProductState.ts @@ -1,7 +1,10 @@ +import Product from './Product'; + export interface PagedProductList { start: number, perPage: number, - items: any[] + total: number, + items: Product[] } export default interface ProductState { @@ -13,9 +16,9 @@ export default interface ProductState { current_options: any, current_configuration: any, parent: any, - list: any[] | PagedProductList, + list: PagedProductList, original: any, - related: any, + related: { [key: string]: Product[] }, offlineImage: any, current_custom_options: any, current_bundle_options: any, diff --git a/core/modules/checkout/components/OrderReview.ts b/core/modules/checkout/components/OrderReview.ts index dfd71b202..4118bb19b 100644 --- a/core/modules/checkout/components/OrderReview.ts +++ b/core/modules/checkout/components/OrderReview.ts @@ -20,12 +20,14 @@ export const OrderReview = { }, computed: { ...mapGetters({ - isVirtualCart: 'cart/isVirtualCart' + isVirtualCart: 'cart/isVirtualCart', + getShippingDetails: 'checkout/getShippingDetails', + getPersonalDetails: 'checkout/getPersonalDetails' }) }, methods: { placeOrder () { - if (this.$store.state.checkout.personalDetails.createAccount) { + if (this.getPersonalDetails.createAccount) { this.register() } else { this.$bus.$emit('checkout-before-placeOrder') @@ -33,12 +35,26 @@ export const OrderReview = { }, async register () { this.$bus.$emit('notification-progress-start', i18n.t('Registering the account ...')) - this.$store.dispatch('user/register', { - email: this.$store.state.checkout.personalDetails.emailAddress, - password: this.$store.state.checkout.personalDetails.password, - firstname: this.$store.state.checkout.personalDetails.firstName, - lastname: this.$store.state.checkout.personalDetails.lastName - }).then(async (result) => { + + try { + const result = await this.$store.dispatch('user/register', { + email: this.getPersonalDetails.emailAddress, + password: this.getPersonalDetails.password, + firstname: this.getPersonalDetails.firstName, + lastname: this.getPersonalDetails.lastName, + addresses: [{ + firstname: this.getShippingDetails.firstName, + lastname: this.getShippingDetails.lastName, + street: [this.getShippingDetails.streetAddress, this.getShippingDetails.apartmentNumber], + city: this.getShippingDetails.city, + ...(this.getShippingDetails.state ? { region: { region: this.getShippingDetails.state } } : {}), + country_id: this.getShippingDetails.country, + postcode: this.getShippingDetails.zipCode, + ...(this.getShippingDetails.phoneNumber ? { telephone: this.getShippingDetails.phoneNumber } : {}), + default_shipping: true + }] + }) + this.$bus.$emit('notification-progress-stop') if (result.code !== 200) { this.onFailure(result) @@ -52,14 +68,17 @@ export const OrderReview = { } } else { this.$bus.$emit('modal-hide', 'modal-signup') - await this.$store.dispatch('user/setCurrentUser', result.result) // set current user data to process it with the current order + await this.$store.dispatch('user/login', { + username: this.getPersonalDetails.emailAddress, + password: this.getPersonalDetails.password + }) this.$bus.$emit('checkout-before-placeOrder', result.result.id) this.onSuccess() } - }).catch(err => { + } catch (err) { this.$bus.$emit('notification-progress-stop') Logger.error(err, 'checkout')() - }) + } } } } diff --git a/core/modules/checkout/components/Payment.ts b/core/modules/checkout/components/Payment.ts index fe7832786..72c8f530d 100644 --- a/core/modules/checkout/components/Payment.ts +++ b/core/modules/checkout/components/Payment.ts @@ -15,7 +15,7 @@ export const Payment = { return { isFilled: false, countries: Countries, - payment: this.$store.state.checkout.paymentDetails, + payment: this.$store.getters['checkout/getPaymentDetails'], generateInvoice: false, sendToShippingAddress: false, sendToBillingAddress: false @@ -27,7 +27,8 @@ export const Payment = { shippingDetails: (state: RootState) => state.checkout.shippingDetails }), ...mapGetters({ - paymentMethods: 'payment/paymentMethods', + paymentMethods: 'checkout/getPaymentMethods', + paymentDetails: 'checkout/getPaymentDetails', isVirtualCart: 'cart/isVirtualCart' }) }, @@ -119,7 +120,7 @@ export const Payment = { } } if (!initialized) { - this.payment = { + this.payment = this.paymentDetails || { firstName: '', lastName: '', company: '', @@ -143,7 +144,7 @@ export const Payment = { } if (!this.sendToBillingAddress && !this.sendToShippingAddress) { - this.payment = this.$store.state.checkout.paymentDetails + this.payment = this.paymentDetails } }, copyShippingToBillingAddress () { @@ -187,7 +188,7 @@ export const Payment = { } if (!this.sendToBillingAddress && !this.sendToShippingAddress) { - this.payment = this.$store.state.checkout.paymentDetails + this.payment = this.paymentDetails this.generateInvoice = false } }, @@ -232,6 +233,10 @@ export const Payment = { // Let anyone listening know that we've changed payment method, usually a payment extension. this.$bus.$emit('checkout-payment-method-changed', this.payment.paymentMethod) + }, + changeCountry () { + this.$store.dispatch('checkout/updatePaymentDetails', { country: this.payment.country }) + this.$store.dispatch('cart/syncPaymentMethods', { forceServerSync: true }) } } } diff --git a/core/modules/checkout/components/Shipping.ts b/core/modules/checkout/components/Shipping.ts index 2fc7ef3f6..a2e59bd92 100644 --- a/core/modules/checkout/components/Shipping.ts +++ b/core/modules/checkout/components/Shipping.ts @@ -42,13 +42,13 @@ export const Shipping = { currentUser: (state: RootState) => state.user.current }), ...mapGetters({ - shippingMethods: 'shipping/shippingMethods' + shippingMethods: 'checkout/getShippingMethods' }), checkoutShippingDetails () { return this.$store.state.checkout.shippingDetails }, paymentMethod () { - return this.$store.state.payment.methods + return this.$store.getters['checkout/getPaymentMethods'] } }, watch: { @@ -60,14 +60,19 @@ export const Shipping = { shipToMyAddress: { handler () { this.useMyAddress() - } + }, + immediate: true } }, mounted () { + this.checkDefaultShippingAddress() this.checkDefaultShippingMethod() this.changeShippingMethod() }, methods: { + checkDefaultShippingAddress () { + this.shipToMyAddress = this.hasShippingDetails() + }, checkDefaultShippingMethod () { if (!this.shipping.shippingMethod || this.notInMethods(this.shipping.shippingMethod)) { let shipping = this.shippingMethods.find(item => item.default) diff --git a/core/modules/checkout/hooks/afterRegistration.ts b/core/modules/checkout/hooks/afterRegistration.ts deleted file mode 100644 index a6028fc24..000000000 --- a/core/modules/checkout/hooks/afterRegistration.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as types from './../store/checkout/mutation-types' - -export function afterRegistration ({ Vue, config, store, isServer }) { - store.subscribe((mutation, state) => { - const type = mutation.type - - if ( - type.endsWith(types.CHECKOUT_SAVE_PERSONAL_DETAILS) - ) { - Vue.prototype.$db.checkoutFieldsCollection.setItem('personal-details', state.checkout.personalDetails).catch((reason) => { - console.error(reason) // it doesn't work on SSR - }) // populate cache - } - - if ( - type.endsWith(types.CHECKOUT_SAVE_SHIPPING_DETAILS) - ) { - Vue.prototype.$db.checkoutFieldsCollection.setItem('shipping-details', state.checkout.shippingDetails).catch((reason) => { - console.error(reason) // it doesn't work on SSR - }) // populate cache - } - - if ( - type.endsWith(types.CHECKOUT_SAVE_PAYMENT_DETAILS) - ) { - Vue.prototype.$db.checkoutFieldsCollection.setItem('payment-details', state.checkout.paymentDetails).catch((reason) => { - console.error(reason) // it doesn't work on SSR - }) // populate cache - } - }) -} diff --git a/core/modules/checkout/hooks/beforeRegistration.ts b/core/modules/checkout/hooks/beforeRegistration.ts deleted file mode 100644 index 6db5ad25f..000000000 --- a/core/modules/checkout/hooks/beforeRegistration.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as localForage from 'localforage' -import UniversalStorage from '@vue-storefront/core/store/lib/storage' -import { currentStoreView } from '@vue-storefront/core/lib/multistore' - -export function beforeRegistration ({ Vue, config, store, isServer }) { - const storeView = currentStoreView() - const dbNamePrefix = storeView.storeCode ? storeView.storeCode + '-' : '' - - Vue.prototype.$db.checkoutFieldsCollection = new UniversalStorage(localForage.createInstance({ - name: (config.storeViews.commonCache ? '' : dbNamePrefix) + 'shop', - storeName: 'checkoutFieldValues', - driver: localForage[config.localForage.defaultDrivers['checkoutFieldValues']] - })) -} diff --git a/core/modules/checkout/index.ts b/core/modules/checkout/index.ts index c2af66941..e6ff6ee64 100644 --- a/core/modules/checkout/index.ts +++ b/core/modules/checkout/index.ts @@ -1,18 +1,42 @@ +import { StorefrontModule } from '@vue-storefront/core/lib/modules' import { checkoutModule } from './store/checkout' import { paymentModule } from './store/payment' import { shippingModule } from './store/shipping' -import { beforeRegistration } from './hooks/beforeRegistration' -import { afterRegistration } from './hooks/afterRegistration' -import { createModule } from '@vue-storefront/core/lib/module' +import * as types from './store/checkout/mutation-types' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' -export const KEY = 'checkout' -export const Checkout = createModule({ - key: KEY, - store: { modules: [ - { key: 'shipping', module: shippingModule }, - { key: 'payment', module: paymentModule }, - { key: 'checkout', module: checkoutModule } - ] }, - beforeRegistration, - afterRegistration -}) +export const CheckoutModule: StorefrontModule = function ({store}) { + StorageManager.init('checkout') + + store.registerModule('shipping', shippingModule) + store.registerModule('payment', paymentModule) + store.registerModule('checkout', checkoutModule) + + store.subscribe((mutation, state) => { + const type = mutation.type + + if ( + 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 + }) // populate cache + } + + if ( + 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 + }) // populate cache + } + + if ( + 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 + }) // populate cache + } + }) +} diff --git a/core/modules/checkout/store/checkout/actions.ts b/core/modules/checkout/store/checkout/actions.ts index 58d1a02e7..4ab73b02a 100644 --- a/core/modules/checkout/store/checkout/actions.ts +++ b/core/modules/checkout/store/checkout/actions.ts @@ -1,70 +1,82 @@ -import Vue from 'vue' import { ActionTree } from 'vuex' import * as types from './mutation-types' import RootState from '@vue-storefront/core/types/RootState' import CheckoutState from '../../types/CheckoutState' import { Logger } from '@vue-storefront/core/lib/logger' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' const actions: ActionTree = { - /** - * Place order - send it to service worker queue - * @param {Object} commit method - * @param {Object} order order data to be send - */ - async placeOrder ({ state, commit, dispatch }, { order }) { + async placeOrder ({ dispatch }, { order }) { try { - const result = await dispatch('order/placeOrder', order, {root: true}) + 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}) - if (state.personalDetails.createAccount) { - commit(types.CHECKOUT_DROP_PASSWORD) - } + await dispatch('updateOrderTimestamp') + await dispatch('cart/clear', { recreateAndSyncCart: true }, { root: true }) + await dispatch('dropPassword') } } catch (e) { Logger.error(e, 'checkout')() } }, - setModifiedAt ({ commit }, timestamp) { + async updateOrderTimestamp () { + const userStorage = StorageManager.get('user') + await userStorage.setItem('last-cart-bypass-ts', new Date().getTime()) + }, + async dropPassword ({ commit, state }) { + if (state.personalDetails.createAccount) { + commit(types.CHECKOUT_DROP_PASSWORD) + } + }, + async setModifiedAt ({ commit }, timestamp) { commit(types.CHECKOUT_SET_MODIFIED_AT, timestamp) }, - savePersonalDetails ({ commit }, personalDetails) { - // todo: create and move perdonal details vuex + async savePersonalDetails ({ commit }, personalDetails) { commit(types.CHECKOUT_SAVE_PERSONAL_DETAILS, personalDetails) }, - saveShippingDetails ({ commit }, shippingDetails) { - // todo: move to shipping vuex + async saveShippingDetails ({ commit }, shippingDetails) { commit(types.CHECKOUT_SAVE_SHIPPING_DETAILS, shippingDetails) }, - savePaymentDetails ({ commit }, paymentDetails) { - // todo: move to payment vuex + async savePaymentDetails ({ commit }, paymentDetails) { commit(types.CHECKOUT_SAVE_PAYMENT_DETAILS, paymentDetails) }, - load ({ commit }) { - Vue.prototype.$db.checkoutFieldsCollection.getItem('personal-details', (err, details) => { - if (err) throw new Error(err) - if (details) { - commit(types.CHECKOUT_LOAD_PERSONAL_DETAILS, details) - } - }) - Vue.prototype.$db.checkoutFieldsCollection.getItem('shipping-details', (err, details) => { - if (err) throw new Error(err) - if (details) { - commit(types.CHECKOUT_LOAD_SHIPPING_DETAILS, details) - } - }) - Vue.prototype.$db.checkoutFieldsCollection.getItem('payment-details', (err, details) => { - if (err) throw new Error(err) - if (details) { - commit(types.CHECKOUT_LOAD_PAYMENT_DETAILS, details) - } - }) + async load ({ commit }) { + const checkoutStorage = StorageManager.get('checkout') + const personalDetails = await checkoutStorage.getItem('personal-details') + const shippingDetails = await checkoutStorage.getItem('shipping-details') + const paymentDetails = await checkoutStorage.getItem('payment-details') + + if (personalDetails) { + commit(types.CHECKOUT_LOAD_PERSONAL_DETAILS, personalDetails) + } + + if (shippingDetails) { + commit(types.CHECKOUT_LOAD_SHIPPING_DETAILS, shippingDetails) + } + + if (paymentDetails) { + commit(types.CHECKOUT_LOAD_PAYMENT_DETAILS, paymentDetails) + } }, - updatePropValue ({ commit }, payload) { + async updatePropValue ({ commit }, payload) { commit(types.CHECKOUT_UPDATE_PROP_VALUE, payload) }, - setThankYouPage ({ commit }, payload) { + async setThankYouPage ({ commit }, payload) { commit(types.CHECKOUT_SET_THANKYOU, payload) + }, + async addPaymentMethod ({ commit }, paymentMethod) { + commit(types.CHECKOUT_ADD_PAYMENT_METHOD, paymentMethod) + }, + async replacePaymentMethods ({ commit }, paymentMethods) { + commit(types.CHECKOUT_SET_PAYMENT_METHODS, paymentMethods) + }, + async addShippingMethod ({ commit }, shippingMethod) { + commit(types.CHECKOUT_ADD_SHIPPING_METHOD, shippingMethod) + }, + async replaceShippingMethods ({ commit }, shippingMethods) { + commit(types.CHECKOUT_SET_SHIPPING_METHODS, shippingMethods) + }, + async updatePaymentDetails ({ commit }, updateData) { + commit(types.CHECKOUT_UPDATE_PAYMENT_DETAILS, updateData) } } diff --git a/core/modules/checkout/store/checkout/getters.ts b/core/modules/checkout/store/checkout/getters.ts index c2ae65f6e..56dd61e3e 100644 --- a/core/modules/checkout/store/checkout/getters.ts +++ b/core/modules/checkout/store/checkout/getters.ts @@ -3,12 +3,30 @@ import CheckoutState from '../../types/CheckoutState' import RootState from '@vue-storefront/core/types/RootState' const getters: GetterTree = { - getShippingDetails: state => state.shippingDetails, + getShippingDetails: (state, getters, rootState) => { + if (!state.shippingDetails.country) { + return { ...state.shippingDetails, country: rootState.storeView.tax.defaultCountry } + } + + return state.shippingDetails + }, getPersonalDetails: state => state.personalDetails, getPaymentDetails: state => state.paymentDetails, isThankYouPage: state => state.isThankYouPage, getModifiedAt: state => state.modifiedAt, - isUserInCheckout: state => ((Date.now() - state.modifiedAt) <= (60 * 30 * 1000)) // TODO: maybe refactor because it's timestamped for now; if user is in the checkout longer than 30min and will log in then the cart will be synced anyway + isUserInCheckout: state => ((Date.now() - state.modifiedAt) <= (60 * 30 * 1000)), + getPaymentMethods: (state, getters, rootState, rootGetters) => { + const isVirtualCart = rootGetters['cart/isVirtualCart'] + + return state.paymentMethods.filter(method => !isVirtualCart || method.code !== 'cashondelivery') + }, + getDefaultPaymentMethod: (state, getters) => getters.getPaymentMethods.find(item => item.default), + getNotServerPaymentMethods: (state, getters) => + getters.getPaymentMethods.filter((itm) => + (typeof itm !== 'object' || !itm.is_server_method) + ), + getShippingMethods: state => state.shippingMethods, + getDefaultShippingMethod: state => state.shippingMethods.find(item => item.default) } export default getters diff --git a/core/modules/checkout/store/checkout/index.ts b/core/modules/checkout/store/checkout/index.ts index cddb11c8e..99049f648 100644 --- a/core/modules/checkout/store/checkout/index.ts +++ b/core/modules/checkout/store/checkout/index.ts @@ -4,11 +4,14 @@ import getters from './getters' import mutations from './mutations' import RootState from '@vue-storefront/core/types/RootState' import CheckoutState from '../../types/CheckoutState' +import config from 'config' export const checkoutModule: Module = { namespaced: true, state: { order: {}, + paymentMethods: [], + shippingMethods: config.shipping.methods, personalDetails: { firstName: '', lastName: '', diff --git a/core/modules/checkout/store/checkout/mutation-types.ts b/core/modules/checkout/store/checkout/mutation-types.ts index 4384b6bde..6ff6c22c7 100644 --- a/core/modules/checkout/store/checkout/mutation-types.ts +++ b/core/modules/checkout/store/checkout/mutation-types.ts @@ -10,3 +10,8 @@ export const CHECKOUT_UPDATE_PROP_VALUE = SN_CHECKOUT + '/UPDATE_PROP_VALUE' export const CHECKOUT_DROP_PASSWORD = SN_CHECKOUT + '/DROP_PASSWORD' export const CHECKOUT_SET_THANKYOU = SN_CHECKOUT + '/SET_THANKYOU' export const CHECKOUT_SET_MODIFIED_AT = SN_CHECKOUT + '/SET_MODIFIEDAT' +export const CHECKOUT_ADD_PAYMENT_METHOD = SN_CHECKOUT + '/ADD_PAYMENT_METHOD' +export const CHECKOUT_SET_PAYMENT_METHODS = SN_CHECKOUT + '/SET_PAYMENT_METHODS' +export const CHECKOUT_ADD_SHIPPING_METHOD = SN_CHECKOUT + '/ADD_SHIPPING_METHOD' +export const CHECKOUT_SET_SHIPPING_METHODS = SN_CHECKOUT + '/SET_SHIPPING_METHOD' +export const CHECKOUT_UPDATE_PAYMENT_DETAILS = SN_CHECKOUT + '/UPDATE_PAYMENT_DETAILS' diff --git a/core/modules/checkout/store/checkout/mutations.ts b/core/modules/checkout/store/checkout/mutations.ts index 0b9d47165..b9be63b6c 100644 --- a/core/modules/checkout/store/checkout/mutations.ts +++ b/core/modules/checkout/store/checkout/mutations.ts @@ -1,14 +1,13 @@ import { MutationTree } from 'vuex' import * as types from './mutation-types' import CheckoutState from '../../types/CheckoutState' -import { ORDER_PLACE_ORDER } from '@vue-storefront/core/modules/order/store/mutation-types' const mutations: MutationTree = { /** * Setup current order object * @param {Object} order Object */ - [ORDER_PLACE_ORDER] (state, order) { + [types.CHECKOUT_PLACE_ORDER] (state, order) { state.order = order }, [types.CHECKOUT_SET_MODIFIED_AT] (state, timestamp) { @@ -41,6 +40,21 @@ const mutations: MutationTree = { }, [types.CHECKOUT_SET_THANKYOU] (state, payload) { state.isThankYouPage = payload + }, + [types.CHECKOUT_ADD_PAYMENT_METHOD] (state, paymentMethod) { + state.paymentMethods.push(paymentMethod) + }, + [types.CHECKOUT_SET_PAYMENT_METHODS] (state, paymentMethods = []) { + state.paymentMethods = paymentMethods + }, + [types.CHECKOUT_ADD_SHIPPING_METHOD] (state, shippingMethods) { + state.shippingMethods.push(shippingMethods) + }, + [types.CHECKOUT_SET_SHIPPING_METHODS] (state, shippingMethods = []) { + state.shippingMethods = shippingMethods + }, + [types.CHECKOUT_UPDATE_PAYMENT_DETAILS] (state, updateData = {}) { + state.paymentDetails = Object.assign({}, state.paymentDetails, updateData) } } diff --git a/core/modules/checkout/store/payment/index.ts b/core/modules/checkout/store/payment/index.ts index c30650fb5..38da27ef3 100644 --- a/core/modules/checkout/store/payment/index.ts +++ b/core/modules/checkout/store/payment/index.ts @@ -1,35 +1,29 @@ import { Module } from 'vuex' import RootState from '@vue-storefront/core/types/RootState' import PaymentState from '../../types/PaymentState' -import rootStore from '@vue-storefront/core/store' +import { Logger } from '@vue-storefront/core/lib/logger' +// @deprecated export const paymentModule: Module = { namespaced: true, - state: { - methods: [] - }, - mutations: { - addMethod (state, paymentMethod) { - state.methods.push(paymentMethod) - }, - replaceMethods (state, paymentMethods) { - state.methods = paymentMethods - } - }, actions: { - addMethod ({commit}, paymentMethod) { - commit('addMethod', paymentMethod) + addMethod ({ dispatch }, paymentMethod) { + Logger.error('The action payment/addMethod has been deprecated please change to checkout/addPaymentMethod')() + + dispatch('checkout/addPaymentMethod', paymentMethod, { root: true }) }, - replaceMethods ({commit}, paymentMethods) { - commit('replaceMethods', paymentMethods) + replaceMethods ({ dispatch }, paymentMethods) { + Logger.error('The action payment/replaceMethods has been deprecated please change to checkout/replacePaymentMethods')() + + dispatch('checkout/replacePaymentMethods', paymentMethods, { root: true }) } }, getters: { - paymentMethods (state) { - const isVirtualCart = rootStore.getters['cart/isVirtualCart'] - return state.methods.filter(method => { - return (!isVirtualCart || method.code !== 'cashondelivery') - }) - } + // @deprecated + paymentMethods: (state, getters, rootState, rootGetters) => rootGetters['checkout/getPaymentMethods'], + // @deprecated + getDefaultPaymentMethod: (state, getters, rootState, rootGetters) => rootGetters['checkout/getDefaultPaymentMethod'], + // @deprecated + getNotServerPaymentMethods: (state, getters, rootState, rootGetters) => rootGetters['checkout/getNotServerPaymentMethods'] } } diff --git a/core/modules/checkout/store/shipping/index.ts b/core/modules/checkout/store/shipping/index.ts index 2318bc44f..a81136699 100644 --- a/core/modules/checkout/store/shipping/index.ts +++ b/core/modules/checkout/store/shipping/index.ts @@ -1,32 +1,27 @@ import { Module } from 'vuex' import RootState from '@vue-storefront/core/types/RootState' import ShippingState from '../../types/ShippingState' -import config from 'config' +import { Logger } from '@vue-storefront/core/lib/logger' +// @deprecated export const shippingModule: Module = { namespaced: true, - state: { - methods: config.shipping.methods - }, - mutations: { - addMethod (state, shippingMethods) { - state.methods.push(shippingMethods) - }, - replaceMethods (state, shippingMethods) { - state.methods = shippingMethods - } - }, actions: { - addMethod ({commit}, shippingMethod) { - commit('addMethod', shippingMethod) + addMethod ({ dispatch }, shippingMethod) { + Logger.error('The action shipping/addMethod has been deprecated please change to checkout/addShippingMethod')() + dispatch('checkout/addShippingMethod', shippingMethod, { root: true }) }, - replaceMethods ({commit}, shippingMethods) { - commit('replaceMethods', shippingMethods) + replaceMethods ({ dispatch }, shippingMethods) { + Logger.error('The action shipping/replaceMethods has been deprecated please change to checkout/replaceShippingMethods')() + dispatch('checkout/replaceShippingMethods', shippingMethods, { root: true }) } }, getters: { - shippingMethods (state) { - return state.methods - } + // @deprecated + shippingMethods: (state, getters, rootState, rootGetters) => rootGetters['checkout/getShippingMethods'], + // @deprecated + getShippingMethods: (state, getters, rootState, rootGetters) => rootGetters['checkout/getShippingMethods'], + // @deprecated + getDefaultShippingMethod: (state, getters, rootState, rootGetters) => rootGetters['checkout/getDefaultShippingMethod'] } } diff --git a/core/modules/checkout/test/unit/components/CartSummary.spec.ts b/core/modules/checkout/test/unit/components/CartSummary.spec.ts index 049893cad..2e933a8f9 100644 --- a/core/modules/checkout/test/unit/components/CartSummary.spec.ts +++ b/core/modules/checkout/test/unit/components/CartSummary.spec.ts @@ -1,16 +1,32 @@ -import {shallowMount} from '@vue/test-utils' - -import { CartSummary } from '../../../components/CartSummary' +import { mountMixin, mountMixinWithStore } from '@vue-storefront/unit-tests/utils'; +import { CartSummary } from '../../../components/CartSummary'; jest.mock('@vue-storefront/core/compatibility/components/blocks/Microcart/Microcart'); describe('CartSummary', () => { it('can be initialized', () => { - const wrapper = shallowMount({ - template: '
', - mixins: [CartSummary] - }); + const wrapper = mountMixin(CartSummary); + expect(wrapper.exists()).toBe(true); expect(wrapper.isVueInstance()).toBe(true); - }) + }); + + it('exposes computed properties', () => { + const mockStore = { + modules: { + cart: { + getters: { + getTotals: jest.fn(() => 1), + isVirtualCart: jest.fn(() => true) + }, + namespaced: true + } + } + }; + + const wrapper = mountMixinWithStore(CartSummary, mockStore); + + expect((wrapper.vm as any).totals).toBeDefined(); + expect((wrapper.vm as any).isVirtualCart).toBeDefined(); + }); }); diff --git a/core/modules/checkout/test/unit/components/OrderReview.spec.ts b/core/modules/checkout/test/unit/components/OrderReview.spec.ts new file mode 100644 index 000000000..d99fad172 --- /dev/null +++ b/core/modules/checkout/test/unit/components/OrderReview.spec.ts @@ -0,0 +1,292 @@ +import { mountMixin, mountMixinWithStore } from '@vue-storefront/unit-tests/utils'; +import { OrderReview } from '../../../components/OrderReview'; + +jest.mock('@vue-storefront/i18n', () => ({ + t: jest.fn(t => t) +})); + +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + error: jest.fn(() => jest.fn()) + } +})); + +describe('OrderReview', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('can be initialized', () => { + const wrapper = mountMixin(OrderReview, { + propsData: { + isActive: true + } + }); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.isVueInstance()).toBe(true); + }); + + it('exposes computed properties', () => { + const mockStore = { + modules: { + cart: { + getters: { + isVirtualCart: jest.fn(() => true) + }, + namespaced: true + }, + checkout: { + getters: { + getShippingDetails: jest.fn(() => ([])), + getPersonalDetails: jest.fn(() => ([])) + }, + namespaced: true + } + } + }; + + const wrapper = mountMixinWithStore(OrderReview, mockStore, { + propsData: { + isActive: true + } + }); + + expect((wrapper.vm as any).isVirtualCart).toBeDefined(); + expect((wrapper.vm as any).getShippingDetails).toBeDefined(); + expect((wrapper.vm as any).getPersonalDetails).toBeDefined(); + }); + + describe('placeOrder method', () => { + let wrapper; + const mockGetPersonalDetails = jest.fn(); + const mockRegisterFn = jest.fn(); + const mockEmitFn = jest.fn(); + + beforeEach(() => { + wrapper = mountMixin(OrderReview, { + propsData: { + isActive: true + }, + computed: { + getPersonalDetails: mockGetPersonalDetails + }, + methods: { + register: mockRegisterFn + }, + mocks: { + $bus: { + $emit: mockEmitFn + } + } + }); + }); + + it('registers an account if it is not created yet', () => { + mockGetPersonalDetails.mockImplementation(() => ({ + createAccount: true + })); + + (wrapper.vm as any).placeOrder(); + + expect(mockRegisterFn).toHaveBeenCalled(); + expect(mockEmitFn).not.toHaveBeenCalled(); + }); + + it('emits an event if account is already created', () => { + mockGetPersonalDetails.mockImplementation(() => ({ + createAccount: false + })); + + (wrapper.vm as any).placeOrder(); + + expect(mockRegisterFn).not.toHaveBeenCalled(); + expect(mockEmitFn).toHaveBeenCalledWith('checkout-before-placeOrder'); + }); + }); + + describe('register method', () => { + let wrapper; + + const mockGetPersonalDetails = jest.fn(() => ({ + emailAddress: 'example email address', + password: 'example password', + firstName: 'example first name', + lastName: 'example last name' + })); + + const mockGetShippingDetails = jest.fn(() => ({ + firstName: 'example first name', + lastName: 'example last name', + streetAddress: 'example street address', + apartmentNumber: 'example apartment number', + city: 'example city', + state: 'example state', + region: 'example region', + country: 'example country', + zipCode: 'example zip code', + phoneNumber: 'example phone number' + })); + + const mockOnSuccessFn = jest.fn(); + const mockOnFailureFn = jest.fn(); + const mockEmitFn = jest.fn(); + + const mockStore = { + modules: { + user: { + actions: { + login: jest.fn(), + register: jest.fn() + }, + namespaced: true + } + } + }; + + beforeEach(() => { + wrapper = mountMixinWithStore(OrderReview, mockStore, { + propsData: { + isActive: true + }, + computed: { + getPersonalDetails: mockGetPersonalDetails, + getShippingDetails: mockGetShippingDetails + }, + methods: { + onSuccess: mockOnSuccessFn, + onFailure: mockOnFailureFn + }, + mocks: { + $bus: { + $emit: mockEmitFn + } + } + }); + }); + + it('dispatches user/register action with proper payload', () => { + (wrapper.vm as any).register(); + + expect(mockStore.modules.user.actions.register).toHaveBeenCalledWith(expect.anything(), { + email: 'example email address', + password: 'example password', + firstname: 'example first name', + lastname: 'example last name', + addresses: [{ + firstname: 'example first name', + lastname: 'example last name', + street: ['example street address', 'example apartment number'], + city: 'example city', + region: { + region: 'example state' + }, + country_id: 'example country', + postcode: 'example zip code', + telephone: 'example phone number', + default_shipping: true + }] + }, undefined); + }); + + it('emits events about start and stop of notification progress', async () => { + const result = { + code: 500, + result: 'example error message' + }; + + mockStore.modules.user.actions.register.mockImplementation(() => Promise.resolve(result)); + + await (wrapper.vm as any).register(); + + expect(mockEmitFn).toHaveBeenCalledTimes(2); + expect(mockEmitFn).toHaveBeenNthCalledWith(1, 'notification-progress-start', 'Registering the account ...'); + expect(mockEmitFn).toHaveBeenNthCalledWith(2, 'notification-progress-stop'); + }); + + it('catches thrown error and sends event about stop of notification progress', async () => { + mockStore.modules.user.actions.register.mockImplementation(() => { throw Error('Error') }); + + await (wrapper.vm as any).register(); + + expect(mockEmitFn).toHaveBeenCalledWith('notification-progress-stop'); + }); + + describe('failed result if return code is other than 200', () => { + it('calls onFailure callback', async () => { + const result = { + code: 500, + result: 'example error message' + }; + + mockStore.modules.user.actions.register.mockImplementation(() => Promise.resolve(result)); + + await (wrapper.vm as any).register(); + + expect(mockOnFailureFn).toHaveBeenCalledWith(result); + expect(mockOnSuccessFn).not.toHaveBeenCalled(); + expect(mockEmitFn).not.toHaveBeenCalledWith('checkout-before-placeOrder', expect.anything()); + }); + + it('emits event to indicate validation error with password if error includes a word "password"', async () => { + const result = { + code: 500, + result: 'example error message with password' + }; + + mockStore.modules.user.actions.register.mockImplementation(() => Promise.resolve(result)); + + await (wrapper.vm as any).register(); + + expect(mockEmitFn).toHaveBeenCalledWith('checkout-after-validationError', 'password'); + }); + + it('emits event to indicate validation error with email address if error includes a word "email"', async () => { + const result = { + code: 500, + result: 'example error message with email' + }; + + mockStore.modules.user.actions.register.mockImplementation(() => Promise.resolve(result)); + + await (wrapper.vm as any).register(); + + expect(mockEmitFn).toHaveBeenCalledWith('checkout-after-validationError', 'email-address'); + }); + }); + + describe('successful result if return code is 200', () => { + it('calls onSuccess callback and emits proper events', async () => { + const result = { + code: 200, + result: { id: 42 } + }; + + mockStore.modules.user.actions.register.mockImplementation(() => Promise.resolve(result)); + + await (wrapper.vm as any).register(); + + expect(mockOnSuccessFn).toHaveBeenCalled(); + expect(mockOnFailureFn).not.toHaveBeenCalled(); + expect(mockEmitFn).toHaveBeenCalledWith('modal-hide', 'modal-signup'); + expect(mockEmitFn).toHaveBeenCalledWith('checkout-before-placeOrder', 42); + }); + + it('dispatches user/login action', async () => { + const result = { + code: 200, + result: { id: 42 } + }; + + mockStore.modules.user.actions.register.mockImplementation(() => Promise.resolve(result)); + + await (wrapper.vm as any).register(); + + expect(mockStore.modules.user.actions.login).toHaveBeenCalledWith(expect.anything(), { + username: 'example email address', + password: 'example password' + }, undefined); + }); + }); + }); +}); diff --git a/core/modules/checkout/test/unit/components/Payment.spec.ts b/core/modules/checkout/test/unit/components/Payment.spec.ts new file mode 100644 index 000000000..f61a24353 --- /dev/null +++ b/core/modules/checkout/test/unit/components/Payment.spec.ts @@ -0,0 +1,599 @@ +import { mountMixinWithStore } from '@vue-storefront/unit-tests/utils'; +import { Payment } from '../../../components/Payment'; + +describe('Payment', () => { + let mockStore; + let mockMountingOptions; + let mockMethods; + let mockHooks; + + beforeEach(() => { + jest.clearAllMocks(); + + mockStore = { + modules: { + cart: { + getters: { + isVirtualCart: jest.fn(() => true) + }, + namespaced: true + }, + checkout: { + state: { + shippingDetails: {} + }, + getters: { + getPaymentMethods: jest.fn(() => ([])), + getPaymentDetails: jest.fn(() => ({ + paymentMethod: '', + firstName: '', + company: '', + country: '' + })) + }, + namespaced: true + }, + user: { + state: { + current: {} + }, + namespaced: true + } + } + }; + + mockMountingOptions = { + propsData: { + isActive: true + }, + mocks: { + $bus: { + $emit: jest.fn() + } + } + }; + + mockMethods = Object.entries(Payment.methods) + .reduce((result, [methodName]) => { + result[methodName] = jest.spyOn(Payment.methods, methodName as keyof typeof Payment.methods) + .mockImplementation(jest.fn()); + + return result; + }, {}); + + mockHooks = ['beforeCreate', 'created', 'beforeMount', 'mounted', 'beforeUpdate', 'updated', 'beforeDestroy', 'destroyed'] + .reduce((result, hookName) => { + if (Payment[hookName]) { + result[hookName] = jest.spyOn(Payment, hookName as any) + .mockImplementation(jest.fn()); + } + + return result; + }, {}); + }); + + it('can be initialized', () => { + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.isVueInstance()).toBe(true); + }); + + it('exposes computed properties', () => { + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + expect((wrapper.vm as any).currentUser).toBeDefined(); + expect((wrapper.vm as any).shippingDetails).toBeDefined(); + expect((wrapper.vm as any).paymentMethods).toBeDefined(); + expect((wrapper.vm as any).paymentDetails).toBeDefined(); + expect((wrapper.vm as any).isVirtualCart).toBeDefined(); + }); + + describe('hooks', () => { + describe('created hook', () => { + beforeEach(() => { + mockHooks['created'].mockRestore(); + }); + + it('should initialize payment method as "cashondelivery" if payment method is not configured', () => { + mockMethods['notInMethods'].mockReturnValue(false); + mockStore.modules.checkout.getters.getPaymentMethods.mockImplementation(() => ([])); + mockStore.modules.checkout.getters.getPaymentDetails.mockImplementation(() => ({ + paymentMethod: '' + })); + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + expect((wrapper.vm as any).payment.paymentMethod).toBe('cashondelivery'); + }); + + it('should initialize payment method as "cashondelivery" if payment method is not supported', () => { + mockMethods['notInMethods'].mockReturnValue(true); + mockStore.modules.checkout.getters.getPaymentMethods.mockImplementation(() => ([])); + mockStore.modules.checkout.getters.getPaymentDetails.mockImplementation(() => ({ + paymentMethod: 'not supported payment method' + })); + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + expect((wrapper.vm as any).payment.paymentMethod).toBe('cashondelivery'); + }); + + it('should initialize payment method as first one from all payment methods if it is not configured', () => { + mockMethods['notInMethods'].mockReturnValue(true); + mockStore.modules.checkout.getters.getPaymentMethods.mockImplementation(() => ([ + { code: 'first payment method' }, { code: 'second payment method' } + ])); + mockStore.modules.checkout.getters.getPaymentDetails.mockImplementation(() => ({ + paymentMethod: 'not supported payment method' + })); + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + expect((wrapper.vm as any).payment.paymentMethod).toBe('first payment method'); + }); + }); + + describe('mounted hook', () => { + beforeEach(() => { + mockHooks['mounted'].mockRestore(); + }); + + it('should initialize billing address if payment is from individual customer and then change payment method', () => { + mockStore.modules.checkout.getters.getPaymentDetails.mockImplementation(() => ({ + firstName: 'example first name', + company: '' + })); + + mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + expect(mockMethods['initializeBillingAddress']).toHaveBeenCalled(); + expect(mockMethods['changePaymentMethod']).toHaveBeenCalled(); + }); + + it('should mark invoice generation and do not initialize billing address if payment is from company and then change payment method', () => { + mockStore.modules.checkout.getters.getPaymentDetails.mockImplementation(() => ({ + firstName: '', + company: 'example company' + })); + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + expect(mockMethods['initializeBillingAddress']).not.toHaveBeenCalled(); + expect(mockMethods['changePaymentMethod']).toHaveBeenCalled(); + expect((wrapper.vm as any).generateInvoice).toBeTruthy(); + }); + }); + }); + + describe('watchers', () => { + it('should call copyShippingToBillingAddress method if "send to shipping address" flag is configured', () => { + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + wrapper.setData({ sendToShippingAddress: true }); + (wrapper.vm as any).$options.watch.shippingDetails.handler.call(wrapper.vm); + + expect(mockMethods['copyShippingToBillingAddress']).toHaveBeenCalled(); + }); + + it('should not call copyShippingToBillingAddress method if "send to shipping address" flag is not configured', () => { + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + wrapper.setData({ sendToShippingAddress: false }); + (wrapper.vm as any).$options.watch.shippingDetails.handler.call(wrapper.vm); + + expect(mockMethods['copyShippingToBillingAddress']).not.toHaveBeenCalled(); + }); + + it('should call useShippingAddress method if "send to shipping address" flag is changed', () => { + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + (wrapper.vm as any).$options.watch.sendToShippingAddress.handler.call(wrapper.vm); + + expect(mockMethods['useShippingAddress']).toHaveBeenCalled(); + }); + + it('should call useBillingAddress method if "send to billing address" flag is changed', () => { + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + (wrapper.vm as any).$options.watch.sendToBillingAddress.handler.call(wrapper.vm); + + expect(mockMethods['useBillingAddress']).toHaveBeenCalled(); + }); + + it('should call useGenerateInvoice method if "generate invoice" flag is changed', () => { + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + (wrapper.vm as any).$options.watch.generateInvoice.handler.call(wrapper.vm); + + expect(mockMethods['useGenerateInvoice']).toHaveBeenCalled(); + }); + }); + + describe('methods', () => { + it('sendDataToCheckout method should emit an event and set flag', () => { + mockMethods['sendDataToCheckout'].mockRestore(); + + const paymentDetails = { + firstName: 'example first name', + company: 'example company', + country: 'example country' + }; + + mockStore.modules.checkout.getters.getPaymentDetails.mockImplementation(() => paymentDetails); + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + (wrapper.vm as any).sendDataToCheckout(); + + expect(mockMountingOptions.mocks.$bus.$emit).toHaveBeenCalledWith('checkout-after-paymentDetails', paymentDetails, undefined); + expect((wrapper.vm as any).isFilled).toBe(true); + }); + + it('edit method should emit event if flag is set', () => { + mockMethods['edit'].mockRestore(); + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + wrapper.setData({ isFilled: true }); + (wrapper.vm as any).edit(); + + expect(mockMountingOptions.mocks.$bus.$emit).toHaveBeenCalledWith('checkout-before-edit', 'payment'); + }); + + it('edit method should not emit event if flag is not set', () => { + mockMethods['edit'].mockRestore(); + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + wrapper.setData({ isFilled: false }); + (wrapper.vm as any).edit(); + + expect(mockMountingOptions.mocks.$bus.$emit).not.toHaveBeenCalled(); + }); + + it('hasBillingData method should inform if current user has default_billing own property', () => { + mockMethods['hasBillingData'].mockRestore(); + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + mockStore.modules.user.state.current = { default_billing: true }; + expect((wrapper.vm as any).hasBillingData()).toBe(true); + + mockStore.modules.user.state.current = {}; + expect((wrapper.vm as any).hasBillingData()).toBe(false); + }); + + it('initializeBillingAddress method should init payment properties with empty strings if current user and payment details are not set', () => { + mockMethods['initializeBillingAddress'].mockRestore(); + mockMountingOptions.computed = { + currentUser: jest.fn(), + paymentDetails: jest.fn() + }; + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + (wrapper.vm as any).initializeBillingAddress(); + + expect((wrapper.vm as any).payment).toEqual({ + firstName: '', + lastName: '', + company: '', + country: '', + state: '', + city: '', + streetAddress: '', + apartmentNumber: '', + postcode: '', + zipCode: '', + phoneNumber: '', + taxId: '', + paymentMethod: '' + }); + }); + + it('initializeBillingAddress method should copy billing address from address id from current user', () => { + mockMethods['initializeBillingAddress'].mockRestore(); + mockMountingOptions.computed = { + currentUser: jest.fn(() => ({ + default_billing: 123, + addresses: [ + { + id: 123, + firstname: 'example first name', + lastname: 'example last name', + company: 'example company', + country_id: 'example country', + region: { region: 'example region' }, + city: 'example city', + street: ['example street', 'example apartment number'], + postcode: 'example post code', + vat_id: 'example vat id', + telephone: 'example telephone' + } + ] + })), + paymentMethods: jest.fn(() => ([{ code: 'example payment method' }])), + paymentDetails: jest.fn() + }; + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + (wrapper.vm as any).initializeBillingAddress(); + + expect((wrapper.vm as any).payment).toEqual({ + firstName: 'example first name', + lastName: 'example last name', + company: 'example company', + country: 'example country', + state: 'example region', + city: 'example city', + streetAddress: 'example street', + apartmentNumber: 'example apartment number', + zipCode: 'example post code', + phoneNumber: 'example telephone', + taxId: 'example vat id', + paymentMethod: 'example payment method' + }); + expect((wrapper.vm as any).generateInvoice).toBe(true); + expect((wrapper.vm as any).sendToBillingAddress).toBe(true); + }); + + it('useShippingAddress method should call copyShippingToBillingAddress if shipping address is set', () => { + mockMethods['useShippingAddress'].mockRestore(); + mockMountingOptions.methods = { + copyShippingToBillingAddress: jest.fn() + }; + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + wrapper.setData({ sendToShippingAddress: true }); + (wrapper.vm as any).useShippingAddress(); + + expect(mockMountingOptions.methods.copyShippingToBillingAddress).toHaveBeenCalled(); + expect((wrapper.vm as any).sendToBillingAddress).toBe(false); + }); + + it('useShippingAddress method should not call copyShippingToBillingAddress if shipping address is not set', () => { + mockMethods['useShippingAddress'].mockRestore(); + + const paymentDetails = { + firstName: 'example first name', + lastName: 'example last name', + company: 'example company', + country: 'example country', + state: 'example region', + city: 'example city', + streetAddress: 'example street', + apartmentNumber: 'example apartment number', + zipCode: 'example post code', + phoneNumber: 'example telephone', + taxId: 'example vat id', + paymentMethod: 'example payment method' + }; + + mockStore.modules.checkout.getters.getPaymentDetails.mockImplementation(() => paymentDetails); + mockMountingOptions.methods = { + copyShippingToBillingAddress: jest.fn() + }; + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + wrapper.setData({ sendToBillingAddress: false, sendToShippingAddress: false }); + (wrapper.vm as any).useShippingAddress(); + + expect(mockMountingOptions.methods.copyShippingToBillingAddress).not.toHaveBeenCalled(); + expect((wrapper.vm as any).payment).toEqual(paymentDetails); + }); + + it('copyShippingToBillingAddress method should copy data from shipping address', () => { + mockMethods['copyShippingToBillingAddress'].mockRestore(); + + const paymentDetails = { + firstName: 'example first name', + lastName: 'example last name', + country: 'example country', + state: 'example region', + city: 'example city', + streetAddress: 'example street', + apartmentNumber: 'example apartment number', + zipCode: 'example post code', + phoneNumber: 'example telephone' + }; + + mockStore.modules.checkout.state.shippingDetails = paymentDetails; + mockStore.modules.checkout.getters.getPaymentMethods.mockImplementation(() => ([ + { code: 'first payment method' }, { code: 'second payment method' } + ])); + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + (wrapper.vm as any).copyShippingToBillingAddress(); + + expect((wrapper.vm as any).payment).toEqual({ + ...paymentDetails, + paymentMethod: 'first payment method' + }); + }); + + it('useBillingAddress method should copy billing address from address id from current user', () => { + mockMethods['useBillingAddress'].mockRestore(); + mockMountingOptions.computed = { + currentUser: jest.fn(() => ({ + default_billing: 123, + addresses: [ + { + id: 123, + firstname: 'example first name', + lastname: 'example last name', + company: 'example company', + country_id: 'example country', + region: { region: 'example region' }, + city: 'example city', + street: ['example street', 'example apartment number'], + postcode: 'example post code', + vat_id: 'example vat id', + telephone: 'example telephone' + } + ] + })), + paymentMethods: jest.fn(() => ([{ code: 'example payment method' }])) + }; + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + wrapper.setData({ sendToBillingAddress: true }); + (wrapper.vm as any).useBillingAddress(); + + expect((wrapper.vm as any).payment).toEqual({ + firstName: 'example first name', + lastName: 'example last name', + company: 'example company', + country: 'example country', + state: 'example region', + city: 'example city', + streetAddress: 'example street', + apartmentNumber: 'example apartment number', + zipCode: 'example post code', + phoneNumber: 'example telephone', + taxId: 'example vat id', + paymentMethod: 'example payment method' + }); + expect((wrapper.vm as any).generateInvoice).toBe(true); + expect((wrapper.vm as any).sendToShippingAddress).toBe(false); + }); + + it('useBillingAddress method should copy billing address from payment details if address is not set', () => { + mockMethods['useBillingAddress'].mockRestore(); + + const paymentDetails = { + firstName: 'example first name', + lastName: 'example last name', + company: 'example company', + country: 'example country', + state: 'example region', + city: 'example city', + streetAddress: 'example street', + apartmentNumber: 'example apartment number', + zipCode: 'example post code', + phoneNumber: 'example telephone', + taxId: 'example vat id', + paymentMethod: 'example payment method' + }; + + mockStore.modules.checkout.getters.getPaymentDetails.mockImplementation(() => paymentDetails); + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + wrapper.setData({ sendToBillingAddress: false, sendToShippingAddress: false }); + (wrapper.vm as any).useBillingAddress(); + + expect((wrapper.vm as any).payment).toEqual(paymentDetails); + expect((wrapper.vm as any).generateInvoice).toBe(false); + }); + + it('useGenerateInvoice method should clear company and taxId fields if generateInvoice is not set', () => { + mockMethods['useGenerateInvoice'].mockRestore(); + mockStore.modules.checkout.getters.getPaymentDetails.mockImplementation(() => ({ + company: 'example company', + taxId: 'example taxId' + })); + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + wrapper.setData({ generateInvoice: false }); + (wrapper.vm as any).useGenerateInvoice(); + + expect((wrapper.vm as any).payment.company).toBe(''); + expect((wrapper.vm as any).payment.taxId).toBe(''); + }); + + it('useGenerateInvoice method should not clear company and taxId fields if generateInvoice is set', () => { + mockMethods['useGenerateInvoice'].mockRestore(); + mockStore.modules.checkout.getters.getPaymentDetails.mockImplementation(() => ({ + company: 'example company', + taxId: 'example taxId' + })); + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + wrapper.setData({ generateInvoice: true }); + (wrapper.vm as any).useGenerateInvoice(); + + expect((wrapper.vm as any).payment.company).toBe('example company'); + expect((wrapper.vm as any).payment.taxId).toBe('example taxId'); + }); + + it('getCountryName method should return country name from payment', () => { + mockMethods['getCountryName'].mockRestore(); + mockStore.modules.checkout.getters.getPaymentDetails.mockImplementation(() => ({ + country: 'PL' + })); + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + const countryName = (wrapper.vm as any).getCountryName(); + + expect(countryName).toBe('Poland'); + }); + + it('getCountryName method should return empty string if country has not been found', () => { + mockMethods['getCountryName'].mockRestore(); + mockStore.modules.checkout.getters.getPaymentDetails.mockImplementation(() => ({ + country: 'invalid country code' + })); + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + const countryName = (wrapper.vm as any).getCountryName(); + + expect(countryName).toBe(''); + }); + + it('getPaymentMethod method should return payment name', () => { + mockMethods['getPaymentMethod'].mockRestore(); + mockStore.modules.checkout.getters.getPaymentDetails.mockImplementation(() => ({ + paymentMethod: 'second payment method' + })); + mockStore.modules.checkout.getters.getPaymentMethods.mockImplementation(() => ([ + { code: 'first payment method', name: 'payment 1' }, { code: 'second payment method', name: 'payment 2' } + ])); + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + const paymentMethod = (wrapper.vm as any).getPaymentMethod(); + + expect(paymentMethod).toEqual({ title: 'payment 2' }); + }); + + it('getPaymentMethod method should return empty name if it is not found', () => { + mockMethods['getPaymentMethod'].mockRestore(); + mockStore.modules.checkout.getters.getPaymentDetails.mockImplementation(() => ({ + paymentMethod: 'invalid payment method' + })); + mockStore.modules.checkout.getters.getPaymentMethods.mockImplementation(() => ([ + { code: 'first payment method', name: 'payment 1' }, { code: 'second payment method', name: 'payment 2' } + ])); + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + const paymentMethod = (wrapper.vm as any).getPaymentMethod(); + + expect(paymentMethod).toEqual({ name: '' }); + }); + + it('notInMethods method should inform if given payment method is not supported', () => { + mockMethods['notInMethods'].mockRestore(); + mockStore.modules.checkout.getters.getPaymentMethods.mockImplementation(() => ([ + { code: 'first payment method', name: 'payment 1' }, { code: 'second payment method', name: 'payment 2' } + ])); + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + + expect((wrapper.vm as any).notInMethods('second payment method')).toBe(false); + expect((wrapper.vm as any).notInMethods('invalid payment method')).toBe(true); + }); + + it('changePaymentMethod method should emit an event', () => { + mockMethods['changePaymentMethod'].mockRestore(); + + const wrapper = mountMixinWithStore(Payment, mockStore, mockMountingOptions); + (wrapper.vm as any).changePaymentMethod(); + + expect(mockMountingOptions.mocks.$bus.$emit).toHaveBeenCalledWith('checkout-payment-method-changed', expect.anything()); + }); + }); +}); diff --git a/core/modules/checkout/test/unit/components/PersonalDetails.spec.ts b/core/modules/checkout/test/unit/components/PersonalDetails.spec.ts new file mode 100644 index 000000000..80b568682 --- /dev/null +++ b/core/modules/checkout/test/unit/components/PersonalDetails.spec.ts @@ -0,0 +1,170 @@ +import { mountMixinWithStore } from '@vue-storefront/unit-tests/utils'; +import { PersonalDetails } from '../../../components/PersonalDetails'; + +describe('PersonalDetails', () => { + let mockStore; + let mockMountingOptions; + + beforeEach(() => { + jest.clearAllMocks(); + + mockStore = { + modules: { + cart: { + getters: { + isVirtualCart: jest.fn(() => true) + }, + namespaced: true + }, + checkout: { + state: { + personalDetails: {} + }, + namespaced: true + }, + user: { + state: { + current: {} + }, + namespaced: true + } + } + }; + + mockMountingOptions = { + propsData: { + isActive: true + }, + mocks: { + $bus: { + $emit: jest.fn(), + $on: jest.fn(), + $off: jest.fn() + } + } + }; + }); + + it('can be initialized', () => { + const wrapper = mountMixinWithStore(PersonalDetails, mockStore, mockMountingOptions); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.isVueInstance()).toBe(true); + }); + + it('exposes computed properties', () => { + const wrapper = mountMixinWithStore(PersonalDetails, mockStore, mockMountingOptions); + + expect((wrapper.vm as any).currentUser).toBeDefined(); + expect((wrapper.vm as any).isVirtualCart).toBeDefined(); + }); + + describe('hooks', () => { + it('beforeMount hook should start subscription for user-after-loggedin event', () => { + const wrapper = mountMixinWithStore(PersonalDetails, mockStore, mockMountingOptions); + + expect(mockMountingOptions.mocks.$bus.$on).toHaveBeenCalledWith('user-after-loggedin', (wrapper.vm as any).onLoggedIn); + }); + + it('destroyed hook should stop subscription for user-after-loggedin event', () => { + const wrapper = mountMixinWithStore(PersonalDetails, mockStore, mockMountingOptions); + + wrapper.destroy(); + + expect(mockMountingOptions.mocks.$bus.$off).toHaveBeenCalledWith('user-after-loggedin', (wrapper.vm as any).onLoggedIn); + }); + + it('updated hook should set focus on password field', () => { + const wrapper = mountMixinWithStore(PersonalDetails, mockStore, mockMountingOptions); + (wrapper.vm as any).$refs.password = { setFocus: jest.fn() }; + + wrapper.setData({ isValidationError: false }); + wrapper.setProps({ focusedField: 'password' }); + + expect((wrapper.vm as any).isValidationError).toBe(true); + expect((wrapper.vm as any).password).toBe(''); + expect((wrapper.vm as any).rPassword).toBe(''); + expect((wrapper.vm as any).$refs.password.setFocus).toHaveBeenCalledWith('password'); + }); + }); + + describe('methods', () => { + it('onLoggedIn method should set names and email', () => { + const personalDetails = { + firstname: 'example first name', + lastname: 'example last name', + email: 'example email' + }; + const wrapper = mountMixinWithStore(PersonalDetails, mockStore, mockMountingOptions); + + (wrapper.vm as any).onLoggedIn(personalDetails); + + expect((wrapper.vm as any).personalDetails).toEqual({ + firstName: personalDetails.firstname, + lastName: personalDetails.lastname, + emailAddress: personalDetails.email + }); + }); + + it('sendDataToCheckout method should create new account if flag is set', () => { + const wrapper = mountMixinWithStore(PersonalDetails, mockStore, mockMountingOptions); + + wrapper.setData({ + createAccount: true, + password: 'example password' + }); + (wrapper.vm as any).sendDataToCheckout(); + + expect((wrapper.vm as any).personalDetails).toEqual({ + password: 'example password', + createAccount: true + }); + }); + + it('sendDataToCheckout method should not create new account if flag is not set', () => { + const wrapper = mountMixinWithStore(PersonalDetails, mockStore, mockMountingOptions); + + wrapper.setData({ createAccount: false }); + (wrapper.vm as any).sendDataToCheckout(); + + expect((wrapper.vm as any).personalDetails).toEqual({ createAccount: false }); + }); + + it('sendDataToCheckout method should emit event and init `filled` and `validation error` flags', () => { + const wrapper = mountMixinWithStore(PersonalDetails, mockStore, mockMountingOptions); + + wrapper.setData({ createAccount: false }); + (wrapper.vm as any).sendDataToCheckout(); + + expect(mockMountingOptions.mocks.$bus.$emit).toHaveBeenCalledWith('checkout-after-personalDetails', { createAccount: false }, undefined); + expect((wrapper.vm as any).isFilled).toBe(true); + expect((wrapper.vm as any).isValidationError).toBe(false); + }); + + it('edit method should emit event if flag is set', () => { + const wrapper = mountMixinWithStore(PersonalDetails, mockStore, mockMountingOptions); + + wrapper.setData({ isFilled: true }); + (wrapper.vm as any).edit(); + + expect(mockMountingOptions.mocks.$bus.$emit).toHaveBeenCalledWith('checkout-before-edit', 'personalDetails'); + }); + + it('edit method should not emit event if flag is not set', () => { + const wrapper = mountMixinWithStore(PersonalDetails, mockStore, mockMountingOptions); + + wrapper.setData({ isFilled: false }); + (wrapper.vm as any).edit(); + + expect(mockMountingOptions.mocks.$bus.$emit).not.toHaveBeenCalled(); + }); + + it('gotoAccount method should emit event', () => { + const wrapper = mountMixinWithStore(PersonalDetails, mockStore, mockMountingOptions); + + (wrapper.vm as any).gotoAccount(); + + expect(mockMountingOptions.mocks.$bus.$emit).toHaveBeenCalledWith('modal-show', 'modal-signup'); + }); + }); +}); diff --git a/core/modules/checkout/test/unit/components/Product.spec.ts b/core/modules/checkout/test/unit/components/Product.spec.ts new file mode 100644 index 000000000..75a18379f --- /dev/null +++ b/core/modules/checkout/test/unit/components/Product.spec.ts @@ -0,0 +1,73 @@ +import { mountMixin } from '@vue-storefront/unit-tests/utils'; +import { Product } from '../../../components/Product'; + +describe('Product', () => { + let mockMountingOptions; + + beforeEach(() => { + jest.clearAllMocks(); + + mockMountingOptions = { + propsData: { + product: { + image: 'example image', + sku: 'example sku' + } + }, + mocks: { + $bus: { + $emit: jest.fn(), + $on: jest.fn(), + $off: jest.fn() + } + }, + methods: { + getThumbnail: jest.fn(() => '') + } + }; + }); + + it('can be initialized', () => { + const wrapper = mountMixin(Product, mockMountingOptions); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.isVueInstance()).toBe(true); + }); + + it('exposes computed properties', () => { + const wrapper = mountMixin(Product, mockMountingOptions); + + expect((wrapper.vm as any).thumbnail).toBeDefined(); + }); + + describe('hooks', () => { + it('beforeMount hook should start subscription for cart-after-itemchanged event', () => { + const wrapper = mountMixin(Product, mockMountingOptions); + + expect(mockMountingOptions.mocks.$bus.$on).toHaveBeenCalledWith('cart-after-itemchanged', (wrapper.vm as any).onProductChanged); + }); + + it('beforeDestroy hook should stop subscription for user-after-itemchanged event', () => { + const wrapper = mountMixin(Product, mockMountingOptions); + + wrapper.destroy(); + + expect(mockMountingOptions.mocks.$bus.$off).toHaveBeenCalledWith('cart-after-itemchanged', (wrapper.vm as any).onProductChanged); + }); + }); + + describe('methods', () => { + it('onProductChanged method should update component only if product has changed', () => { + const wrapper = mountMixin(Product, mockMountingOptions); + const mockForceUpdateFn = jest.spyOn((wrapper.vm as any), '$forceUpdate'); + + mockForceUpdateFn.mockClear(); + (wrapper.vm as any).onProductChanged({ item: { sku: 'example sku' } }); + expect(mockForceUpdateFn).toHaveBeenCalled(); + + mockForceUpdateFn.mockClear(); + (wrapper.vm as any).onProductChanged({ item: { sku: 'another sku' } }); + expect(mockForceUpdateFn).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/core/modules/checkout/test/unit/components/Shipping.spec.ts b/core/modules/checkout/test/unit/components/Shipping.spec.ts new file mode 100644 index 000000000..7b004cb05 --- /dev/null +++ b/core/modules/checkout/test/unit/components/Shipping.spec.ts @@ -0,0 +1,470 @@ +import { mountMixinWithStore } from '@vue-storefront/unit-tests/utils'; +import { Shipping } from '../../../components/Shipping'; + +describe('Shipping', () => { + let mockStore; + let mockMountingOptions; + let mockMethods; + let mockHooks; + + beforeEach(() => { + jest.clearAllMocks(); + + mockStore = { + modules: { + checkout: { + state: { + shippingDetails: {} + }, + getters: { + getShippingMethods: jest.fn(() => ([])), + getPaymentMethods: jest.fn(() => ([])) + }, + actions: { + updatePropValue: jest.fn() + }, + namespaced: true + }, + user: { + state: { + current: {} + }, + namespaced: true + } + } + }; + + mockMountingOptions = { + propsData: { + isActive: true + }, + mocks: { + $bus: { + $emit: jest.fn(), + $on: jest.fn(), + $off: jest.fn() + } + } + }; + + mockMethods = Object.entries(Shipping.methods) + .reduce((result, [methodName]) => { + result[methodName] = jest.spyOn(Shipping.methods, methodName as keyof typeof Shipping.methods) + .mockImplementation(jest.fn()); + + return result; + }, {}); + + mockHooks = ['beforeCreate', 'created', 'beforeMount', 'mounted', 'beforeUpdate', 'updated', 'beforeDestroy', 'destroyed'] + .reduce((result, hookName) => { + if (Shipping[hookName]) { + result[hookName] = jest.spyOn(Shipping, hookName as any) + .mockImplementation(jest.fn()); + } + + return result; + }, {}); + }); + + it('can be initialized', () => { + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.isVueInstance()).toBe(true); + }); + + it('exposes computed properties', () => { + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + + expect((wrapper.vm as any).currentUser).toBeDefined(); + expect((wrapper.vm as any).shippingMethods).toBeDefined(); + expect((wrapper.vm as any).checkoutShippingDetails).toBeDefined(); + expect((wrapper.vm as any).paymentMethod).toBeDefined(); + }); + + describe('hooks', () => { + it('beforeMount hook should start subscription for checkout-after-personalDetails and checkout-after-shippingset events', () => { + mockHooks['beforeMount'].mockRestore(); + + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + + expect(mockMountingOptions.mocks.$bus.$on) + .toHaveBeenCalledWith('checkout-after-personalDetails', (wrapper.vm as any).onAfterPersonalDetails); + expect(mockMountingOptions.mocks.$bus.$on) + .toHaveBeenCalledWith('checkout-after-shippingset', (wrapper.vm as any).onAfterShippingSet); + }); + + it('beforeDestroy hook should stop subscription for checkout-after-personalDetails and checkout-after-shippingset events', () => { + mockHooks['beforeDestroy'].mockRestore(); + + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + wrapper.destroy(); + + expect(mockMountingOptions.mocks.$bus.$off) + .toHaveBeenCalledWith('checkout-after-personalDetails', (wrapper.vm as any).onAfterPersonalDetails); + expect(mockMountingOptions.mocks.$bus.$off) + .toHaveBeenCalledWith('checkout-after-shippingset', (wrapper.vm as any).onAfterShippingSet); + }); + + it('mounted hook should call shipping details methods', () => { + mockHooks['mounted'].mockRestore(); + + mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + + expect(mockMethods['checkDefaultShippingAddress']).toHaveBeenCalled(); + expect(mockMethods['checkDefaultShippingMethod']).toHaveBeenCalled(); + expect(mockMethods['changeShippingMethod']).toHaveBeenCalled(); + }); + }); + + describe('watchers', () => { + it('should call checkDefaultShippingMethod method if shipping methods have changed', () => { + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + + (wrapper.vm as any).$options.watch.shippingMethods.handler.call(wrapper.vm); + + expect(mockMethods['checkDefaultShippingMethod']).toHaveBeenCalled(); + }); + + it('should call useMyAddress method if shipping address has changed', () => { + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + + (wrapper.vm as any).$options.watch.shipToMyAddress.handler.call(wrapper.vm); + + expect(mockMethods['useMyAddress']).toHaveBeenCalled(); + }); + }); + + describe('methods', () => { + it('checkDefaultShippingAddress should check if default shipping address is configured', () => { + mockMethods['checkDefaultShippingAddress'].mockRestore(); + + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + + mockMethods['hasShippingDetails'].mockClear(); + mockMethods['hasShippingDetails'].mockReturnValue(false); + (wrapper.vm as any).checkDefaultShippingAddress(); + + expect(mockMethods['hasShippingDetails']).toHaveBeenCalled(); + expect((wrapper.vm as any).shipToMyAddress).toBe(false); + + mockMethods['hasShippingDetails'].mockClear(); + mockMethods['hasShippingDetails'].mockReturnValue(true); + (wrapper.vm as any).checkDefaultShippingAddress(); + + expect(mockMethods['hasShippingDetails']).toHaveBeenCalled(); + expect((wrapper.vm as any).shipToMyAddress).toBe(true); + }); + + it('checkDefaultShippingMethod should configure default shipping method and carrier if current shipping method is falsy', () => { + mockMethods['checkDefaultShippingMethod'].mockRestore(); + mockStore.modules.checkout.state.shippingDetails.shippingMethod = ''; + mockStore.modules.checkout.getters.getShippingMethods.mockImplementation(() => ([ + { method_code: 'method code 1', carrier_code: 'carrier code 1' }, + { method_code: 'method code 2', carrier_code: 'carrier code 2' }, + { method_code: 'method code 3', carrier_code: 'carrier code 3', default: true } + ])); + + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + + mockMethods['notInMethods'].mockClear(); + mockMethods['notInMethods'].mockReturnValue(true); + (wrapper.vm as any).checkDefaultShippingMethod(); + + expect(mockMethods['notInMethods']).not.toHaveBeenCalled(); + expect((wrapper.vm as any).shipping).toEqual({ shippingMethod: 'method code 3', shippingCarrier: 'carrier code 3' }); + }); + + it('checkDefaultShippingMethod should configure default shipping method and carrier if current shipping method is not supported', () => { + mockMethods['checkDefaultShippingMethod'].mockRestore(); + mockStore.modules.checkout.state.shippingDetails.shippingMethod = 'not supported shipping method'; + mockStore.modules.checkout.getters.getShippingMethods.mockImplementation(() => ([ + { method_code: 'method code 1', carrier_code: 'carrier code 1' }, + { method_code: 'method code 2', carrier_code: 'carrier code 2' }, + { method_code: 'method code 3', carrier_code: 'carrier code 3', default: true } + ])); + + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + + mockMethods['notInMethods'].mockClear(); + mockMethods['notInMethods'].mockReturnValue(true); + (wrapper.vm as any).checkDefaultShippingMethod(); + + expect(mockMethods['notInMethods']).toHaveBeenCalledWith('not supported shipping method'); + expect((wrapper.vm as any).shipping).toEqual({ shippingMethod: 'method code 3', shippingCarrier: 'carrier code 3' }); + }); + + it('onAfterShippingSet should configure shipping data', () => { + mockMethods['onAfterShippingSet'].mockRestore(); + + const shippingData = { shippingMethod: 'method code 3', shippingCarrier: 'carrier code 3' }; + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + + (wrapper.vm as any).onAfterShippingSet(shippingData); + + expect((wrapper.vm as any).shipping).toEqual(shippingData); + expect((wrapper.vm as any).isFilled).toBe(true); + }); + + it('onAfterPersonalDetails should configure personal data by dispatching actions', () => { + mockMethods['onAfterPersonalDetails'].mockRestore(); + + const personalData = { firstName: 'example first name', lastName: 'example last name' }; + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + + wrapper.setData({ isFilled: false }); + (wrapper.vm as any).onAfterPersonalDetails(personalData); + + expect(mockStore.modules.checkout.actions.updatePropValue) + .toHaveBeenCalledWith(expect.anything(), ['firstName', 'example first name'], undefined); + expect(mockStore.modules.checkout.actions.updatePropValue) + .toHaveBeenCalledWith(expect.anything(), ['lastName', 'example last name'], undefined); + }); + + it('sendDataToCheckout should emit event', () => { + mockMethods['sendDataToCheckout'].mockRestore(); + + const shippingData = { shippingMethod: 'method code 3', shippingCarrier: 'carrier code 3' }; + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + + wrapper.setData({ shipping: shippingData }); + (wrapper.vm as any).sendDataToCheckout(); + + expect(mockMountingOptions.mocks.$bus.$emit).toHaveBeenCalledWith('checkout-after-shippingDetails', shippingData, undefined); + expect((wrapper.vm as any).isFilled).toBe(true); + }); + + it('edit should emit event only if form is filled', () => { + mockMethods['edit'].mockRestore(); + + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + + mockMountingOptions.mocks.$bus.$emit.mockClear(); + wrapper.setData({ isFilled: true }); + (wrapper.vm as any).edit(); + + expect(mockMountingOptions.mocks.$bus.$emit).toHaveBeenCalledWith('checkout-before-edit', 'shipping'); + + mockMountingOptions.mocks.$bus.$emit.mockClear(); + wrapper.setData({ isFilled: false }); + (wrapper.vm as any).edit(); + + expect(mockMountingOptions.mocks.$bus.$emit).not.toHaveBeenCalled(); + }); + + it('hasShippingDetails should check if shipping address is configured', () => { + mockMethods['hasShippingDetails'].mockRestore(); + mockStore.modules.user.state.current = {}; + + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + const hasShippingDetails = (wrapper.vm as any).hasShippingDetails(); + + expect(hasShippingDetails).toBe(false); + }); + + it('hasShippingDetails should init default shipping address only if it is configured', () => { + mockMethods['hasShippingDetails'].mockRestore(); + + const defaultAddress = { id: 123, city: 'example city', street: 'example street' }; + + mockStore.modules.user.state.current = { + default_shipping: 123, + addresses: [defaultAddress] + }; + + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + const hasShippingDetails = (wrapper.vm as any).hasShippingDetails(); + + expect(hasShippingDetails).toBe(true); + expect((wrapper.vm as any).myAddressDetails).toEqual(defaultAddress); + }); + + it('useMyAddress should init shipping address from myAddressDetails if shipToMyAddress is set', () => { + mockMethods['useMyAddress'].mockRestore(); + mockStore.modules.checkout.state.shippingDetails = { + shippingMethod: 'example shipping method', + shippingCarrier: 'example shipping carrier' + }; + + const myAddressDetails = { + firstname: 'example first name', + lastname: 'example last name', + country_id: 'example country', + region: { region: 'example region' }, + city: 'example city', + street: ['example street', 'example apartment number'], + postcode: 'example zip code', + telephone: 'example phone number' + }; + + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + wrapper.setData({ shipToMyAddress: true }); + wrapper.setData({ myAddressDetails }); + (wrapper.vm as any).useMyAddress(); + + expect((wrapper.vm as any).shipping).toEqual({ + firstName: 'example first name', + lastName: 'example last name', + country: 'example country', + state: 'example region', + city: 'example city', + streetAddress: 'example street', + apartmentNumber: 'example apartment number', + zipCode: 'example zip code', + phoneNumber: 'example phone number', + shippingMethod: 'example shipping method', + shippingCarrier: 'example shipping carrier' + }); + }); + + it('useMyAddress should init shipping address from shippingDetails if shipToMyAddress is not set', () => { + mockMethods['useMyAddress'].mockRestore(); + mockStore.modules.checkout.state.shippingDetails = { + shippingMethod: 'example shipping method', + shippingCarrier: 'example shipping carrier' + }; + + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + wrapper.setData({ shipToMyAddress: false }); + (wrapper.vm as any).useMyAddress(); + + expect((wrapper.vm as any).shipping).toEqual(mockStore.modules.checkout.state.shippingDetails); + }); + + it('useMyAddress should call changeCountry method', () => { + mockMethods['useMyAddress'].mockRestore(); + + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + (wrapper.vm as any).useMyAddress(); + + expect(mockMethods['changeCountry']).toHaveBeenCalled(); + }); + + it('getShippingMethod should return empty method_title and amount if shipping method is different than current one', () => { + mockMethods['getShippingMethod'].mockRestore(); + mockStore.modules.checkout.state.shippingDetails.shippingMethod = 'not supported shipping method'; + mockStore.modules.checkout.getters.getShippingMethods.mockImplementation(() => ([ + { method_code: 'method code 1', method_title: 'method title 1', amount: 'amount 1' }, + { method_code: 'method code 2', method_title: 'method title 2', amount: 'amount 2' }, + { method_code: 'method code 3', method_title: 'method title 3', amount: 'amount 3' } + ])); + + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + const shippingMethod = (wrapper.vm as any).getShippingMethod(); + + expect(shippingMethod).toEqual({ method_title: '', amount: '' }); + }); + + it('getShippingMethod should return method_title and amount if it is supported', () => { + mockMethods['getShippingMethod'].mockRestore(); + mockStore.modules.checkout.state.shippingDetails.shippingMethod = 'method code 2'; + mockStore.modules.checkout.getters.getShippingMethods.mockImplementation(() => ([ + { method_code: 'method code 1', method_title: 'method title 1', amount: 'amount 1' }, + { method_code: 'method code 2', method_title: 'method title 2', amount: 'amount 2' }, + { method_code: 'method code 3', method_title: 'method title 3', amount: 'amount 3' } + ])); + + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + const shippingMethod = (wrapper.vm as any).getShippingMethod(); + + expect(shippingMethod).toEqual({ method_title: 'method title 2', amount: 'amount 2' }); + }); + + it('getCountryName method should return country name from shipping', () => { + mockMethods['getCountryName'].mockRestore(); + mockStore.modules.checkout.state.shippingDetails.country = 'PL'; + + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + const countryName = (wrapper.vm as any).getCountryName(); + + expect(countryName).toBe('Poland'); + }); + + it('getCountryName method should return empty string if country has not been found', () => { + mockMethods['getCountryName'].mockRestore(); + mockStore.modules.checkout.state.shippingDetails.country = 'invalid country code'; + + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + const countryName = (wrapper.vm as any).getCountryName(); + + expect(countryName).toBe(''); + }); + + it('changeCountry should emit event', () => { + mockMethods['changeCountry'].mockRestore(); + mockStore.modules.checkout.state.shippingDetails.country = 'PL'; + + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + (wrapper.vm as any).changeCountry(); + + expect(mockMountingOptions.mocks.$bus.$emit).toHaveBeenCalledWith('checkout-before-shippingMethods', 'PL'); + }); + + it('getCurrentShippingMethod should return undefined if there are no supported methods', () => { + mockMethods['getCurrentShippingMethod'].mockRestore(); + mockStore.modules.checkout.state.shippingDetails.shippingMethod = 'not supported shipping method'; + mockStore.modules.checkout.getters.getShippingMethods.mockImplementation(() => ([])); + + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + const shippingMethod = (wrapper.vm as any).getCurrentShippingMethod(); + + expect(shippingMethod).toBeUndefined(); + }); + + it('getCurrentShippingMethod should return shipping method details if it is supported', () => { + mockMethods['getCurrentShippingMethod'].mockRestore(); + mockStore.modules.checkout.state.shippingDetails.shippingMethod = 'method code 2'; + mockStore.modules.checkout.getters.getShippingMethods.mockImplementation(() => ([ + { method_code: 'method code 1', method_title: 'method title 1', amount: 'amount 1' }, + { method_code: 'method code 2', method_title: 'method title 2', amount: 'amount 2' }, + { method_code: 'method code 3', method_title: 'method title 3', amount: 'amount 3' } + ])); + + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + const shippingMethod = (wrapper.vm as any).getCurrentShippingMethod(); + + expect(shippingMethod).toEqual({ method_code: 'method code 2', method_title: 'method title 2', amount: 'amount 2' }); + }); + + it('changeShippingMethod should not emit event if there is no current shipping method', () => { + mockMethods['changeShippingMethod'].mockRestore(); + mockMethods['getCurrentShippingMethod'].mockReturnValue(); + + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + (wrapper.vm as any).changeShippingMethod(); + + expect(mockMountingOptions.mocks.$bus.$emit).not.toHaveBeenCalled(); + }); + + it('changeShippingMethod should emit event with current shipping method if it is configured', () => { + mockMethods['changeShippingMethod'].mockRestore(); + mockMethods['getCurrentShippingMethod'].mockReturnValue({ method_code: 'method code', carrier_code: 'carrier code' }); + mockStore.modules.checkout.state.shippingDetails.country = 'PL'; + mockStore.modules.checkout.getters.getPaymentMethods.mockImplementation(() => ([{ code: 'payment code' }])); + + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + (wrapper.vm as any).changeShippingMethod(); + + expect(mockMountingOptions.mocks.$bus.$emit).toHaveBeenCalledWith('checkout-after-shippingMethodChanged', { + country: 'PL', + method_code: 'method code', + carrier_code: 'carrier code', + payment_method: 'payment code' + }); + }); + + it('notInMethods method should inform if given shipping method is not supported', () => { + mockMethods['notInMethods'].mockRestore(); + mockStore.modules.checkout.getters.getShippingMethods.mockImplementation(() => ([ + { method_code: 'method code 1' }, + { method_code: 'method code 2' }, + { method_code: 'method code 3' } + ])); + + const wrapper = mountMixinWithStore(Shipping, mockStore, mockMountingOptions); + + expect((wrapper.vm as any).notInMethods('method code 2')).toBe(false); + expect((wrapper.vm as any).notInMethods('invalid method code')).toBe(true); + }); + }); +}); diff --git a/core/modules/checkout/test/unit/index.spec.ts b/core/modules/checkout/test/unit/index.spec.ts new file mode 100644 index 000000000..11dab280c --- /dev/null +++ b/core/modules/checkout/test/unit/index.spec.ts @@ -0,0 +1,101 @@ +import { StorageManager } from '@vue-storefront/core/lib/storage-manager'; +import { CheckoutModule } from '@vue-storefront/core/modules/checkout'; +import { checkoutModule } from '@vue-storefront/core/modules/checkout/store/checkout'; +import { paymentModule } from '@vue-storefront/core/modules/checkout/store/payment'; +import { shippingModule } from '@vue-storefront/core/modules/checkout/store/shipping'; +import * as types from '@vue-storefront/core/modules/checkout/store/checkout/mutation-types'; + +jest.mock('@vue-storefront/core/helpers', () => ({ + once: () => jest.fn() +})); + +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ + StorageManager: { + init: jest.fn(), + get: jest.fn() + } +})); + +describe('CheckoutModule', () => { + let store; + let subscription; + let mockSetItem; + + beforeEach(() => { + jest.clearAllMocks(); + + store = { + registerModule: jest.fn(), + subscribe: jest.fn(fn => { + subscription = fn; + }) + }; + + mockSetItem = jest.fn().mockResolvedValue({}); + + (StorageManager.get as jest.Mock).mockImplementation(() => ({ + setItem: mockSetItem + })); + }); + + it('should init and register modules', () => { + CheckoutModule({ store } as any); + + expect(StorageManager.init).toHaveBeenCalledWith('checkout'); + expect(store.registerModule).toHaveBeenCalledWith('shipping', shippingModule); + expect(store.registerModule).toHaveBeenCalledWith('payment', paymentModule); + expect(store.registerModule).toHaveBeenCalledWith('checkout', checkoutModule); + expect(store.subscribe).toHaveBeenCalled(); + }); + + it('should subscribe and set personal details on updates', () => { + const mockState = { + checkout: { + personalDetails: { + firstName: 'example first name', + lastName: 'example last name' + } + } + }; + + CheckoutModule({ store } as any); + subscription({ type: types.CHECKOUT_SAVE_PERSONAL_DETAILS }, mockState); + + expect(StorageManager.get).toHaveBeenCalledWith('checkout'); + expect(mockSetItem).toHaveBeenCalledWith('personal-details', mockState.checkout.personalDetails); + }); + + it('should subscribe and set shipping details on updates', () => { + const mockState = { + checkout: { + shippingDetails: { + firstName: 'example first name', + lastName: 'example last name' + } + } + }; + + CheckoutModule({ store } as any); + subscription({ type: types.CHECKOUT_SAVE_SHIPPING_DETAILS }, mockState); + + expect(StorageManager.get).toHaveBeenCalledWith('checkout'); + expect(mockSetItem).toHaveBeenCalledWith('shipping-details', mockState.checkout.shippingDetails); + }); + + it('should subscribe and set payment details on updates', () => { + const mockState = { + checkout: { + paymentDetails: { + firstName: 'example first name', + lastName: 'example last name' + } + } + }; + + CheckoutModule({ store } as any); + subscription({ type: types.CHECKOUT_SAVE_PAYMENT_DETAILS }, mockState); + + expect(StorageManager.get).toHaveBeenCalledWith('checkout'); + expect(mockSetItem).toHaveBeenCalledWith('payment-details', mockState.checkout.paymentDetails); + }); +}); diff --git a/core/modules/checkout/test/unit/store/checkout/actions.spec.ts b/core/modules/checkout/test/unit/store/checkout/actions.spec.ts new file mode 100644 index 000000000..cba2de367 --- /dev/null +++ b/core/modules/checkout/test/unit/store/checkout/actions.spec.ts @@ -0,0 +1,235 @@ +import * as types from '@vue-storefront/core/modules/checkout/store/checkout/mutation-types'; +import checkoutActions from '@vue-storefront/core/modules/checkout/store/checkout/actions'; +import { StorageManager } from '@vue-storefront/core/lib/storage-manager'; +import { Logger } from '@vue-storefront/core/lib/logger'; + +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ + StorageManager: { + get: jest.fn() + } +})); + +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + error: jest.fn(() => jest.fn()) + } +})); + +describe('Checkout actions', () => { + let mockContext; + + beforeEach(() => { + jest.clearAllMocks(); + + mockContext = { + commit: jest.fn(), + dispatch: jest.fn() + }; + }); + + describe('placeOrder', () => { + let order; + + beforeEach(() => { + order = { + sku: 123456789 + }; + }); + + it('should place order if return code is successful', async () => { + mockContext.dispatch.mockResolvedValue({ resultCode: 200 }); + await (checkoutActions as any).placeOrder(mockContext, { order }); + + expect(mockContext.dispatch).toHaveBeenCalledTimes(4); + expect(mockContext.dispatch).toHaveBeenNthCalledWith(1, 'order/placeOrder', order, { root: true }); + expect(mockContext.dispatch).toHaveBeenNthCalledWith(2, 'updateOrderTimestamp'); + expect(mockContext.dispatch).toHaveBeenNthCalledWith(3, 'cart/clear', { recreateAndSyncCart: true }, { root: true }); + expect(mockContext.dispatch).toHaveBeenNthCalledWith(4, 'dropPassword'); + }); + + it('should place order if return code is missing', async () => { + mockContext.dispatch.mockResolvedValue({ resultCode: undefined }); + await (checkoutActions as any).placeOrder(mockContext, { order }); + + expect(mockContext.dispatch).toHaveBeenCalledTimes(4); + expect(mockContext.dispatch).toHaveBeenNthCalledWith(1, 'order/placeOrder', order, { root: true }); + expect(mockContext.dispatch).toHaveBeenNthCalledWith(2, 'updateOrderTimestamp'); + expect(mockContext.dispatch).toHaveBeenNthCalledWith(3, 'cart/clear', { recreateAndSyncCart: true }, { root: true }); + expect(mockContext.dispatch).toHaveBeenNthCalledWith(4, 'dropPassword'); + }); + + it('should not place order if return code is not successful', async () => { + mockContext.dispatch.mockResolvedValue({ resultCode: 500 }); + await (checkoutActions as any).placeOrder(mockContext, { order }); + + expect(mockContext.dispatch).toHaveBeenCalledTimes(1); + expect(mockContext.dispatch).toHaveBeenNthCalledWith(1, 'order/placeOrder', order, { root: true }); + expect(mockContext.dispatch).not.toHaveBeenNthCalledWith(2, 'updateOrderTimestamp'); + expect(mockContext.dispatch).not.toHaveBeenNthCalledWith(3, 'cart/clear', { recreateAndSyncCart: true }, { root: true }); + expect(mockContext.dispatch).not.toHaveBeenNthCalledWith(4, 'dropPassword'); + }); + + it('should log thrown error', async () => { + mockContext.dispatch.mockImplementation(() => { throw new Error(); }); + await (checkoutActions as any).placeOrder(mockContext, { order }); + + expect(mockContext.dispatch).toHaveBeenCalledTimes(1); + expect(mockContext.dispatch).toHaveBeenNthCalledWith(1, 'order/placeOrder', order, { root: true }); + expect(mockContext.dispatch).not.toHaveBeenNthCalledWith(2, 'updateOrderTimestamp'); + expect(mockContext.dispatch).not.toHaveBeenNthCalledWith(3, 'cart/clear', { recreateAndSyncCart: true }, { root: true }); + expect(mockContext.dispatch).not.toHaveBeenNthCalledWith(4, 'dropPassword'); + expect(Logger.error).toHaveBeenCalled(); + }); + }); + + it('updateOrderTimestamp should update timestamp of order in cache', async () => { + const mockSetItem = jest.fn(() => ({})); + + (StorageManager.get as jest.Mock).mockImplementation(() => ({ + setItem: mockSetItem + })); + + await (checkoutActions as any).updateOrderTimestamp(); + + expect(StorageManager.get).toHaveBeenCalledWith('user'); + expect(mockSetItem).toHaveBeenCalledWith('last-cart-bypass-ts', expect.any(Number)); + }); + + describe('dropPassword', () => { + it('should drop password for account creation', async () => { + mockContext.state = { + personalDetails: { + createAccount: true + } + }; + await (checkoutActions as any).dropPassword(mockContext); + + expect(mockContext.commit).toHaveBeenCalledWith(types.CHECKOUT_DROP_PASSWORD); + }); + + it('should not drop password if account is not created', async () => { + mockContext.state = { + personalDetails: { + createAccount: false + } + }; + await (checkoutActions as any).dropPassword(mockContext); + + expect(mockContext.commit).not.toHaveBeenCalled(); + }); + }); + + it('setModifiedAt should configure modified at date', async () => { + const timestamp = 1234567890; + await (checkoutActions as any).setModifiedAt(mockContext, timestamp); + + expect(mockContext.commit).toHaveBeenCalledWith(types.CHECKOUT_SET_MODIFIED_AT, timestamp); + }); + + it('savePersonalDetails should configure personal details', async () => { + const personalDetails = { + firstName: 'example first name', + lastName: 'example last name' + }; + await (checkoutActions as any).savePersonalDetails(mockContext, personalDetails); + + expect(mockContext.commit).toHaveBeenCalledWith(types.CHECKOUT_SAVE_PERSONAL_DETAILS, personalDetails); + }); + + it('saveShippingDetails should configure shipping details', async () => { + const shippingDetails = { + firstName: 'example first name', + lastName: 'example last name' + }; + await (checkoutActions as any).saveShippingDetails(mockContext, shippingDetails); + + expect(mockContext.commit).toHaveBeenCalledWith(types.CHECKOUT_SAVE_SHIPPING_DETAILS, shippingDetails); + }); + + it('savePaymentDetails should configure payment details', async () => { + const paymentDetails = { + paymentMethod: 'example payment method' + }; + await (checkoutActions as any).savePaymentDetails(mockContext, paymentDetails); + + expect(mockContext.commit).toHaveBeenCalledWith(types.CHECKOUT_SAVE_PAYMENT_DETAILS, paymentDetails); + }); + + it('load personal, shipping and payment details from cache', async () => { + const personalDetails = { + firstName: 'example personal first name', + lastName: 'example personal last name' + }; + const shippingDetails = { + firstName: 'example shipping first name', + lastName: 'example shipping last name' + }; + const paymentDetails = { + paymentMethod: 'example payment method' + }; + const mockGetItem = jest.fn() + .mockResolvedValueOnce(personalDetails) + .mockResolvedValueOnce(shippingDetails) + .mockResolvedValueOnce(paymentDetails); + + (StorageManager.get as jest.Mock).mockImplementation(() => ({ + getItem: mockGetItem + })); + + await (checkoutActions as any).load(mockContext); + + expect(StorageManager.get).toHaveBeenCalledWith('checkout'); + expect(mockGetItem).toHaveBeenCalledWith('personal-details'); + expect(mockGetItem).toHaveBeenCalledWith('shipping-details'); + expect(mockGetItem).toHaveBeenCalledWith('payment-details'); + expect(mockContext.commit).toHaveBeenCalledWith(types.CHECKOUT_LOAD_PERSONAL_DETAILS, personalDetails); + expect(mockContext.commit).toHaveBeenCalledWith(types.CHECKOUT_LOAD_SHIPPING_DETAILS, shippingDetails); + expect(mockContext.commit).toHaveBeenCalledWith(types.CHECKOUT_LOAD_PAYMENT_DETAILS, paymentDetails); + }); + + it('updatePropValue should configure prop value', async () => { + const payload = { + data: 'example data' + }; + await (checkoutActions as any).updatePropValue(mockContext, payload); + + expect(mockContext.commit).toHaveBeenCalledWith(types.CHECKOUT_UPDATE_PROP_VALUE, payload); + }); + + it('setThankYouPage should configure thank you page', async () => { + const payload = { + data: 'example data' + }; + await (checkoutActions as any).setThankYouPage(mockContext, payload); + + expect(mockContext.commit).toHaveBeenCalledWith(types.CHECKOUT_SET_THANKYOU, payload); + }); + + it('addPaymentMethod should configure add payment method', async () => { + const paymentMethod = 'example payment method'; + await (checkoutActions as any).addPaymentMethod(mockContext, paymentMethod); + + expect(mockContext.commit).toHaveBeenCalledWith(types.CHECKOUT_ADD_PAYMENT_METHOD, paymentMethod); + }); + + it('replacePaymentMethods should replace payment method', async () => { + const paymentMethod = 'example payment method'; + await (checkoutActions as any).replacePaymentMethods(mockContext, paymentMethod); + + expect(mockContext.commit).toHaveBeenCalledWith(types.CHECKOUT_SET_PAYMENT_METHODS, paymentMethod); + }); + + it('addShippingMethod should add shipping method', async () => { + const shippingMethod = 'example shipping method'; + await (checkoutActions as any).addShippingMethod(mockContext, shippingMethod); + + expect(mockContext.commit).toHaveBeenCalledWith(types.CHECKOUT_ADD_SHIPPING_METHOD, shippingMethod); + }); + + it('replaceShippingMethods should replace shipping method', async () => { + const shippingMethod = 'example shipping method'; + await (checkoutActions as any).replaceShippingMethods(mockContext, shippingMethod); + + expect(mockContext.commit).toHaveBeenCalledWith(types.CHECKOUT_SET_SHIPPING_METHODS, shippingMethod); + }); +}); diff --git a/core/modules/checkout/test/unit/store/checkout/getters.spec.ts b/core/modules/checkout/test/unit/store/checkout/getters.spec.ts new file mode 100644 index 000000000..9552fab31 --- /dev/null +++ b/core/modules/checkout/test/unit/store/checkout/getters.spec.ts @@ -0,0 +1,172 @@ +import checkoutGetters from '@vue-storefront/core/modules/checkout/store/checkout/getters'; + +describe('Checkout getters', () => { + it('getShippingDetails should return shipping details with default country from rootState if country is missing in shipping details', () => { + const mockState = { + shippingDetails: { + firstName: 'example first name', + lastName: 'example last name', + country: '' + } + }; + + const mockRootState = { + storeView: { + tax: { + defaultCountry: 'Poland' + } + } + }; + + const shippingDetails = (checkoutGetters as any).getShippingDetails(mockState, null, mockRootState); + + expect(shippingDetails).toEqual({ ...mockState.shippingDetails, country: mockRootState.storeView.tax.defaultCountry }); + }); + + it('getShippingDetails should return shipping details with shipping country if it exists', () => { + const mockState = { + shippingDetails: { + firstName: 'example first name', + lastName: 'example last name', + country: 'USA' + } + }; + + const mockRootState = { + storeView: { + tax: { + defaultCountry: 'Poland' + } + } + }; + + const shippingDetails = (checkoutGetters as any).getShippingDetails(mockState, null, mockRootState); + + expect(shippingDetails).toEqual({ ...mockState.shippingDetails }); + }); + + it('getPersonalDetails should return personal details', () => { + const mockState = { + personalDetails: { + firstName: 'example first name', + lastName: 'example last name' + } + }; + + const personalDetails = (checkoutGetters as any).getPersonalDetails(mockState); + + expect(personalDetails).toEqual({ ...mockState.personalDetails }); + }); + + it('getPaymentDetails should return personal details', () => { + const mockState = { + paymentDetails: { + paymentMethod: 'example payment method' + } + }; + + const paymentDetails = (checkoutGetters as any).getPaymentDetails(mockState); + + expect(paymentDetails).toEqual({ ...mockState.paymentDetails }); + }); + + it('isThankYouPage should inform if thank you page must be shown', () => { + const isThankYouPage = (checkoutGetters as any).isThankYouPage({ isThankYouPage: true }); + const isNotThankYouPage = (checkoutGetters as any).isThankYouPage({ isThankYouPage: false }); + + expect(isThankYouPage).toBe(true); + expect(isNotThankYouPage).toBe(false); + }); + + it('getModifiedAt should return modified at time', () => { + const modifiedAt = (checkoutGetters as any).getModifiedAt({ modifiedAt: 1234567890 }); + + expect(modifiedAt).toBe(1234567890); + }); + + it('isUserInCheckout should inform if user is in checkout if it has been modified less than 30 minutes ago', () => { + const isUserInCheckout = (checkoutGetters as any).isUserInCheckout({ modifiedAt: Date.now() - (1000 * 60 * 29) }); + const isUserNotInCheckout = (checkoutGetters as any).isUserInCheckout({ modifiedAt: Date.now() - (1000 * 60 * 31) }); + + expect(isUserInCheckout).toBe(true); + expect(isUserNotInCheckout).toBe(false); + }); + + it('getPaymentMethods should return all configured payment methods if virtual cart is not set', () => { + const mockState = { + paymentMethods: [ + { code: 'example method 1' }, { code: 'example method 2' }, { code: 'cashondelivery' } + ] + }; + const rootGetters = { + 'cart/isVirtualCart': false + }; + + const paymentMethods = (checkoutGetters as any).getPaymentMethods(mockState, null, null, rootGetters); + + expect(paymentMethods).toEqual([{ code: 'example method 1' }, { code: 'example method 2' }, { code: 'cashondelivery' }]); + }); + + it('getPaymentMethods should return all configured payment methods except cashondelivery if virtual cart is set', () => { + const mockState = { + paymentMethods: [ + { code: 'example method 1' }, { code: 'example method 2' }, { code: 'cashondelivery' } + ] + }; + const rootGetters = { + 'cart/isVirtualCart': true + }; + + const paymentMethods = (checkoutGetters as any).getPaymentMethods(mockState, null, null, rootGetters); + + expect(paymentMethods).toEqual([{ code: 'example method 1' }, { code: 'example method 2' }]); + }); + + it('getDefaultPaymentMethod should return default payment method', () => { + const mockGetters = { + getPaymentMethods: [ + { code: 'example method 1' }, { code: 'example method 2', default: true }, { code: 'cashondelivery' } + ] + }; + + const paymentMethod = (checkoutGetters as any).getDefaultPaymentMethod(null, mockGetters); + + expect(paymentMethod).toEqual({ code: 'example method 2', default: true }); + }); + + it('getNotServerPaymentMethods should return methods not for server', () => { + const mockGetters = { + getPaymentMethods: [ + { code: 'example method 1' }, { code: 'example method 2', is_server_method: true } + ] + }; + + const paymentMethods = (checkoutGetters as any).getNotServerPaymentMethods(null, mockGetters); + + expect(paymentMethods).toEqual([{ code: 'example method 1' }]); + }); + + it('getShippingMethods should return shipping methods', () => { + const mockState = { + shippingMethods: [ + { code: 'example method 1' }, { code: 'example method 2' } + ] + }; + + const shippingMethods = (checkoutGetters as any).getShippingMethods(mockState); + + expect(shippingMethods).toEqual([{ code: 'example method 1' }, { code: 'example method 2' }]); + }); + + it('getDefaultShippingMethod should return default shipping method', () => { + const mockState = { + shippingMethods: [ + { code: 'example method 1' }, { code: 'example method 2', default: true } + ] + }; + + const shippingMethod = (checkoutGetters as any).getDefaultShippingMethod(mockState); + + expect(shippingMethod).toEqual({ code: 'example method 2', default: true }); + }); +}); diff --git a/core/modules/checkout/test/unit/store/checkout/mutations.spec.ts b/core/modules/checkout/test/unit/store/checkout/mutations.spec.ts new file mode 100644 index 000000000..bbdfb7de9 --- /dev/null +++ b/core/modules/checkout/test/unit/store/checkout/mutations.spec.ts @@ -0,0 +1,217 @@ +import * as types from '@vue-storefront/core/modules/checkout/store/checkout/mutation-types'; +import checkoutMutations from '@vue-storefront/core/modules/checkout/store/checkout/mutations' + +describe('Checkout mutations', () => { + it('CHECKOUT_PLACE_ORDER should set order', () => { + const order = { id: 1234567890 }; + const mockState = { + order: null + }; + const expectedState = { + order: { ...order } + }; + + (checkoutMutations as any)[types.CHECKOUT_PLACE_ORDER](mockState, order); + + expect(mockState).toEqual(expectedState); + }); + + it('CHECKOUT_SET_MODIFIED_AT should set modified at time', () => { + const mockState = { + modifiedAt: 1 + }; + const expectedState = { + modifiedAt: 1234567890 + }; + + (checkoutMutations as any)[types.CHECKOUT_SET_MODIFIED_AT](mockState, 1234567890); + + expect(mockState).toEqual(expectedState); + }); + + it('CHECKOUT_SAVE_PERSONAL_DETAILS should set personal details', () => { + const personalDetails = { id: 1234567890 }; + const mockState = { + personalDetails: null + }; + const expectedState = { + personalDetails: { ...personalDetails } + }; + + (checkoutMutations as any)[types.CHECKOUT_SAVE_PERSONAL_DETAILS](mockState, personalDetails); + + expect(mockState).toEqual(expectedState); + }); + + it('CHECKOUT_SAVE_SHIPPING_DETAILS should set shipping details', () => { + const shippingDetails = { id: 1234567890 }; + const mockState = { + shippingDetails: null + }; + const expectedState = { + shippingDetails: { ...shippingDetails } + }; + + (checkoutMutations as any)[types.CHECKOUT_SAVE_SHIPPING_DETAILS](mockState, shippingDetails); + + expect(mockState).toEqual(expectedState); + }); + + it('CHECKOUT_SAVE_PAYMENT_DETAILS should set payment details', () => { + const paymentDetails = { id: 1234567890 }; + const mockState = { + paymentDetails: null + }; + const expectedState = { + paymentDetails: { ...paymentDetails } + }; + + (checkoutMutations as any)[types.CHECKOUT_SAVE_PAYMENT_DETAILS](mockState, paymentDetails); + + expect(mockState).toEqual(expectedState); + }); + + it('CHECKOUT_LOAD_PERSONAL_DETAILS should load personal details', () => { + const personalDetails = { id: 1234567890 }; + const mockState = { + personalDetails: null + }; + const expectedState = { + personalDetails: { ...personalDetails } + }; + + (checkoutMutations as any)[types.CHECKOUT_LOAD_PERSONAL_DETAILS](mockState, personalDetails); + + expect(mockState).toEqual(expectedState); + }); + + it('CHECKOUT_LOAD_SHIPPING_DETAILS should load personal details', () => { + const shippingDetails = { id: 1234567890 }; + const mockState = { + shippingDetails: null + }; + const expectedState = { + shippingDetails: { ...shippingDetails } + }; + + (checkoutMutations as any)[types.CHECKOUT_LOAD_SHIPPING_DETAILS](mockState, shippingDetails); + + expect(mockState).toEqual(expectedState); + }); + + it('CHECKOUT_LOAD_PAYMENT_DETAILS should load payment details', () => { + const paymentDetails = { id: 1234567890 }; + const mockState = { + paymentDetails: null + }; + const expectedState = { + paymentDetails: { ...paymentDetails } + }; + + (checkoutMutations as any)[types.CHECKOUT_LOAD_PAYMENT_DETAILS](mockState, paymentDetails); + + expect(mockState).toEqual(expectedState); + }); + + it('CHECKOUT_UPDATE_PROP_VALUE should update shipping property', () => { + const payload = ['prop', 'value']; + const mockState = { + shippingDetails: { + prop: null + } + }; + const expectedState = { + shippingDetails: { + prop: 'value' + } + }; + + (checkoutMutations as any)[types.CHECKOUT_UPDATE_PROP_VALUE](mockState, payload); + + expect(mockState).toEqual(expectedState); + }); + + it('CHECKOUT_DROP_PASSWORD should init password and create account flag', () => { + const mockState = { + personalDetails: { + password: 'example password', + createAccount: true + } + }; + const expectedState = { + personalDetails: { + password: '', + createAccount: false + } + }; + + (checkoutMutations as any)[types.CHECKOUT_DROP_PASSWORD](mockState); + + expect(mockState).toEqual(expectedState); + }); + + it('CHECKOUT_SET_THANKYOU should init thank you page', () => { + const mockState = { + isThankYouPage: false + }; + const expectedState = { + isThankYouPage: true + }; + + (checkoutMutations as any)[types.CHECKOUT_SET_THANKYOU](mockState, true); + + expect(mockState).toEqual(expectedState); + }); + + it('CHECKOUT_ADD_PAYMENT_METHOD should add payment method', () => { + const mockState = { + paymentMethods: [] + }; + const expectedState = { + paymentMethods: [{ code: 'example payment method' }] + }; + + (checkoutMutations as any)[types.CHECKOUT_ADD_PAYMENT_METHOD](mockState, { code: 'example payment method' }); + + expect(mockState).toEqual(expectedState); + }); + + it('CHECKOUT_SET_PAYMENT_METHODS should set payment methods', () => { + const mockState = { + paymentMethods: [{ code: 'previous example payment method' }] + }; + const expectedState = { + paymentMethods: [{ code: 'example payment method' }] + }; + + (checkoutMutations as any)[types.CHECKOUT_SET_PAYMENT_METHODS](mockState, [{ code: 'example payment method' }]); + + expect(mockState).toEqual(expectedState); + }); + + it('CHECKOUT_ADD_SHIPPING_METHOD should add shipping method', () => { + const mockState = { + shippingMethods: [] + }; + const expectedState = { + shippingMethods: [{ code: 'example shipping method' }] + }; + + (checkoutMutations as any)[types.CHECKOUT_ADD_SHIPPING_METHOD](mockState, { code: 'example shipping method' }); + + expect(mockState).toEqual(expectedState); + }); + + it('CHECKOUT_SET_SHIPPING_METHODS should set shipping methods', () => { + const mockState = { + shippingMethods: [{ code: 'previous example shipping method' }] + }; + const expectedState = { + shippingMethods: [{ code: 'example shipping method' }] + }; + + (checkoutMutations as any)[types.CHECKOUT_SET_SHIPPING_METHODS](mockState, [{ code: 'example shipping method' }]); + + expect(mockState).toEqual(expectedState); + }); +}); diff --git a/core/modules/checkout/test/unit/store/payment/index.spec.ts b/core/modules/checkout/test/unit/store/payment/index.spec.ts new file mode 100644 index 000000000..504ab83d4 --- /dev/null +++ b/core/modules/checkout/test/unit/store/payment/index.spec.ts @@ -0,0 +1,65 @@ +import { paymentModule } from '@vue-storefront/core/modules/checkout/store/payment'; + +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + error: jest.fn(() => jest.fn()) + } +})); + +describe('Payment actions', () => { + let mockContext; + + beforeEach(() => { + jest.clearAllMocks(); + + mockContext = { + dispatch: jest.fn() + }; + }); + + it('addMethod should configure new payment method', async () => { + await (paymentModule.actions as any).addMethod(mockContext, 'example payment method'); + + expect(mockContext.dispatch).toHaveBeenCalledWith('checkout/addPaymentMethod', 'example payment method', { root: true }); + }); + + it('replaceMethods should replace payment method', async () => { + await (paymentModule.actions as any).replaceMethods(mockContext, 'example payment method'); + + expect(mockContext.dispatch).toHaveBeenCalledWith('checkout/replacePaymentMethods', 'example payment method', { root: true }); + }); +}); + +describe('Payment getters', () => { + it('paymentMethods should return payment methods from rootStore', () => { + const rootGetters = { + 'checkout/getPaymentMethods': [ + { code: 'example payment method' } + ] + }; + + const personalDetails = (paymentModule.getters as any).paymentMethods(null, null, null, rootGetters); + + expect(personalDetails).toEqual([{ code: 'example payment method' }]); + }); + + it('getDefaultPaymentMethod should return default payment method from rootStore', () => { + const rootGetters = { + 'checkout/getDefaultPaymentMethod': { code: 'example payment method' } + }; + + const personalDetails = (paymentModule.getters as any).getDefaultPaymentMethod(null, null, null, rootGetters); + + expect(personalDetails).toEqual({ code: 'example payment method' }); + }); + + it('getNotServerPaymentMethods should return not server methods from rootStore', () => { + const rootGetters = { + 'checkout/getNotServerPaymentMethods': { code: 'example payment method' } + }; + + const personalDetails = (paymentModule.getters as any).getNotServerPaymentMethods(null, null, null, rootGetters); + + expect(personalDetails).toEqual({ code: 'example payment method' }); + }); +}); diff --git a/core/modules/checkout/test/unit/store/shipping/index.spec.ts b/core/modules/checkout/test/unit/store/shipping/index.spec.ts new file mode 100644 index 000000000..e92333234 --- /dev/null +++ b/core/modules/checkout/test/unit/store/shipping/index.spec.ts @@ -0,0 +1,67 @@ +import { shippingModule } from '@vue-storefront/core/modules/checkout/store/shipping'; + +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + error: jest.fn(() => jest.fn()) + } +})); + +describe('Payment actions', () => { + let mockContext; + + beforeEach(() => { + jest.clearAllMocks(); + + mockContext = { + dispatch: jest.fn() + }; + }); + + it('addMethod should configure new shipping method', async () => { + await (shippingModule.actions as any).addMethod(mockContext, 'example shipping method'); + + expect(mockContext.dispatch).toHaveBeenCalledWith('checkout/addShippingMethod', 'example shipping method', { root: true }); + }); + + it('replaceMethods should replace shipping method', async () => { + await (shippingModule.actions as any).replaceMethods(mockContext, 'example shipping method'); + + expect(mockContext.dispatch).toHaveBeenCalledWith('checkout/replaceShippingMethods', 'example shipping method', { root: true }); + }); +}); + +describe('Shipping getters', () => { + it('shippingMethods should return shipping methods from rootStore', () => { + const rootGetters = { + 'checkout/getShippingMethods': [ + { code: 'example shipping method' } + ] + }; + + const shippingDetails = (shippingModule.getters as any).shippingMethods(null, null, null, rootGetters); + + expect(shippingDetails).toEqual([{ code: 'example shipping method' }]); + }); + + it('getShippingMethods should return shipping methods from rootStore', () => { + const rootGetters = { + 'checkout/getShippingMethods': [ + { code: 'example shipping method' } + ] + }; + + const shippingDetails = (shippingModule.getters as any).getShippingMethods(null, null, null, rootGetters); + + expect(shippingDetails).toEqual([{ code: 'example shipping method' }]); + }); + + it('getDefaultShippingMethod should return default shipping method from rootStore', () => { + const rootGetters = { + 'checkout/getDefaultShippingMethod': { code: 'example shipping method' } + }; + + const personalDetails = (shippingModule.getters as any).getDefaultShippingMethod(null, null, null, rootGetters); + + expect(personalDetails).toEqual({ code: 'example shipping method' }); + }); +}); diff --git a/core/modules/checkout/types/CheckoutState.ts b/core/modules/checkout/types/CheckoutState.ts index 7d2c5fce1..29164f08d 100644 --- a/core/modules/checkout/types/CheckoutState.ts +++ b/core/modules/checkout/types/CheckoutState.ts @@ -1,5 +1,10 @@ +import ShippingDetails from './ShippingDetails' +import PaymentDetails from './PaymentDetails' + export default interface CheckoutState { order: any, + paymentMethods: any[], + shippingMethods: any[], personalDetails: { firstName: string, lastName: string, @@ -7,35 +12,8 @@ export default interface CheckoutState { password: string, createAccount: boolean }, - shippingDetails: { - firstName: string, - lastName: string, - country: string, - streetAddress: string, - apartmentNumber: string, - city: string, - state: string, - region_id: number, - zipCode: string, - phoneNumber: string, - shippingMethod: string - }, - paymentDetails: { - firstName: string, - lastName: string, - company: string, - country: string, - streetAddress: string, - apartmentNumber: string, - city: string, - region_id: number, - state: string, - zipCode: string, - phoneNumber: string, - taxId: string, - paymentMethod: string, - paymentMethodAdditional: any - }, + shippingDetails: ShippingDetails, + paymentDetails: PaymentDetails, isThankYouPage: boolean, modifiedAt: number } diff --git a/core/modules/checkout/types/PaymentDetails.ts b/core/modules/checkout/types/PaymentDetails.ts new file mode 100644 index 000000000..0f14fd254 --- /dev/null +++ b/core/modules/checkout/types/PaymentDetails.ts @@ -0,0 +1,16 @@ +export default interface PaymentDetails { + firstName: string, + lastName: string, + company: string, + country: string, + streetAddress: string, + apartmentNumber: string, + city: string, + region_id: number | string, + state: string, + zipCode: string, + phoneNumber: string, + taxId: string, + paymentMethod: string, + paymentMethodAdditional: any +} diff --git a/core/modules/checkout/types/ShippingDetails.ts b/core/modules/checkout/types/ShippingDetails.ts new file mode 100644 index 000000000..cf35b9364 --- /dev/null +++ b/core/modules/checkout/types/ShippingDetails.ts @@ -0,0 +1,13 @@ +export default interface ShippingDetails { + firstName: string, + lastName: string, + country: string, + streetAddress: string, + apartmentNumber: string, + city: string, + state: string, + region_id: number | string, + zipCode: string, + phoneNumber: string, + shippingMethod: string +} diff --git a/core/modules/cms/helpers/createHierarchyLoadQuery.ts b/core/modules/cms/helpers/createHierarchyLoadQuery.ts new file mode 100644 index 000000000..af47fa051 --- /dev/null +++ b/core/modules/cms/helpers/createHierarchyLoadQuery.ts @@ -0,0 +1,13 @@ +import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' + +const createHierarchyLoadQuery = ({ id }): SearchQuery => { + let query = new SearchQuery() + + if (id) { + query = query.applyFilter({ key: 'identifier', value: { eq: id } }) + } + + return query +} + +export default createHierarchyLoadQuery diff --git a/core/modules/cms/helpers/createLoadingBlockQuery.ts b/core/modules/cms/helpers/createLoadingBlockQuery.ts new file mode 100644 index 000000000..efb913fdc --- /dev/null +++ b/core/modules/cms/helpers/createLoadingBlockQuery.ts @@ -0,0 +1,13 @@ +import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' + +const createLoadingBlockQuery = ({ filterField, filterValues }): SearchQuery => { + let query = new SearchQuery() + + if (filterValues) { + query = query.applyFilter({key: filterField, value: { like: filterValues }}) + } + + return query +} + +export default createLoadingBlockQuery diff --git a/core/modules/cms/helpers/createPageLoadingQuery.ts b/core/modules/cms/helpers/createPageLoadingQuery.ts new file mode 100644 index 000000000..3c1a03f81 --- /dev/null +++ b/core/modules/cms/helpers/createPageLoadingQuery.ts @@ -0,0 +1,13 @@ +import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' + +const createPageLoadingQuery = ({ filterField, filterValues }): SearchQuery => { + let query = new SearchQuery() + + if (filterValues) { + query = query.applyFilter({ key: filterField, value: { like: filterValues } }) + } + + return query +} + +export default createPageLoadingQuery diff --git a/core/modules/cms/helpers/createSingleBlockQuery.ts b/core/modules/cms/helpers/createSingleBlockQuery.ts new file mode 100644 index 000000000..3f975105a --- /dev/null +++ b/core/modules/cms/helpers/createSingleBlockQuery.ts @@ -0,0 +1,13 @@ +import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' + +const createSingleBlockQuery = ({ key, value }): SearchQuery => { + let query = new SearchQuery() + + if (value) { + query = query.applyFilter({ key, value: { like: value } }) + } + + return query +} + +export default createSingleBlockQuery diff --git a/core/modules/cms/helpers/createSinglePageLoadQuery.ts b/core/modules/cms/helpers/createSinglePageLoadQuery.ts new file mode 100644 index 000000000..79463bc57 --- /dev/null +++ b/core/modules/cms/helpers/createSinglePageLoadQuery.ts @@ -0,0 +1,13 @@ +import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' + +const createSinglePageLoadQuery = ({ key, value }): SearchQuery => { + let query = new SearchQuery() + + if (value) { + query = query.applyFilter({ key, value: { like: value } }) + } + + return query +} + +export default createSinglePageLoadQuery diff --git a/core/modules/cms/helpers/index.ts b/core/modules/cms/helpers/index.ts new file mode 100644 index 000000000..9fd71d797 --- /dev/null +++ b/core/modules/cms/helpers/index.ts @@ -0,0 +1,13 @@ +import createLoadingBlockQuery from './createLoadingBlockQuery' +import createSingleBlockQuery from './createSingleBlockQuery' +import createHierarchyLoadQuery from './createHierarchyLoadQuery' +import createPageLoadingQuery from './createPageLoadingQuery' +import createSinglePageLoadQuery from './createSinglePageLoadQuery' + +export { + createLoadingBlockQuery, + createSingleBlockQuery, + createHierarchyLoadQuery, + createPageLoadingQuery, + createSinglePageLoadQuery +} diff --git a/core/modules/cms/hooks/beforeRegistration.ts b/core/modules/cms/hooks/beforeRegistration.ts deleted file mode 100644 index fb341a9b1..000000000 --- a/core/modules/cms/hooks/beforeRegistration.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as localForage from 'localforage' -import UniversalStorage from '@vue-storefront/core/store/lib/storage' -import { currentStoreView } from '@vue-storefront/core/lib/multistore' - -export function beforeRegistration ({ Vue, config, store, isServer }) { - const storeView = currentStoreView() - const dbNamePrefix = storeView.storeCode ? storeView.storeCode + '-' : '' - - Vue.prototype.$db.cmsData = new UniversalStorage(localForage.createInstance({ - name: dbNamePrefix + 'shop', - storeName: 'cms' - })) -} diff --git a/core/modules/cms/index.ts b/core/modules/cms/index.ts index 737e3935f..0e9853970 100644 --- a/core/modules/cms/index.ts +++ b/core/modules/cms/index.ts @@ -1,20 +1,14 @@ import { cmsPageModule } from './store/page' import { cmsBlockModule } from './store/block' import { cmsHierarchyModule } from './store/hierarchy' -import { createModule } from '@vue-storefront/core/lib/module' -import { beforeRegistration } from './hooks/beforeRegistration' -import { plugin } from './store/plugin' -import { initCacheStorage } from '@vue-storefront/core/helpers/initCacheStorage'; +import cmsPersistPlugin from './store/cmsPersistPlugin' +import { StorefrontModule } from '@vue-storefront/core/lib/modules'; +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' -export const KEY = 'cms' -export const cacheStorage = initCacheStorage(KEY) -export const Cms = createModule({ - key: KEY, - store: { modules: [ - { key: 'cmsPage', module: cmsPageModule }, - { key: 'cmsBlock', module: cmsBlockModule }, - { key: 'cmsHierarchy', module: cmsHierarchyModule } - ], - plugin }, - beforeRegistration -}) +export const CmsModule: StorefrontModule = function ({store}) { + StorageManager.init('cms') + store.registerModule('cmsPage', cmsPageModule) + store.registerModule('cmsBlock', cmsBlockModule) + store.registerModule('cmsHierarchy', cmsHierarchyModule) + store.subscribe(cmsPersistPlugin) +} diff --git a/core/modules/cms/store/block/actions.ts b/core/modules/cms/store/block/actions.ts index ac900572e..7dabc28b2 100644 --- a/core/modules/cms/store/block/actions.ts +++ b/core/modules/cms/store/block/actions.ts @@ -1,88 +1,47 @@ import { ActionTree } from 'vuex' import { quickSearchByQuery } from '@vue-storefront/core/lib/search' import * as types from './mutation-types' -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' import RootState from '@vue-storefront/core/types/RootState'; import CmsBlockState from '../../types/CmsBlockState' -import { Logger } from '@vue-storefront/core/lib/logger' +import { createLoadingBlockQuery, createSingleBlockQuery } from '@vue-storefront/core/modules/cms/helpers' const actions: ActionTree = { - - /** - * Retrieve cms blocks - * - * @param context - * @param {any} filterValues - * @param {any} filterField - * @param {any} size - * @param {any} start - * @param {any} excludeFields - * @param {any} includeFields - * @returns {Promise & Promise} - */ - list (context, { filterValues = null, filterField = 'identifier', size = 150, start = 0, excludeFields = null, includeFields = null, skipCache = false }) { - let query = new SearchQuery() - if (filterValues) { - query = query.applyFilter({key: filterField, value: {'like': filterValues}}) - } - if (skipCache || (!context.state.items || context.state.items.length === 0)) { - return quickSearchByQuery({ query, entityType: 'cms_block', excludeFields, includeFields }) - .then((resp) => { - context.commit(types.CMS_BLOCK_UPDATE_CMS_BLOCKS, resp.items) - return resp.items - }) - .catch(err => { - Logger.error(err, 'cms')() - }) - } else { - return new Promise((resolve, reject) => { - let resp = context.state.items - resolve(resp) + async list ({ getters, commit }, { filterValues = null, filterField = 'identifier', size = 150, start = 0, excludeFields = null, includeFields = null, skipCache = false }) { + if (skipCache || !getters.hasItems) { + const blockResponse = await quickSearchByQuery({ + query: createLoadingBlockQuery({ filterField, filterValues }), + entityType: 'cms_block', + size, + start, + excludeFields, + includeFields }) + + commit(types.CMS_BLOCK_UPDATE_CMS_BLOCKS, blockResponse.items) + return blockResponse.items } + + return getters.getCmsBlocks }, + async single ({ getters, commit }, { key = 'identifier', value, excludeFields = null, includeFields = null, skipCache = false }) { + let cmsBlock = getters.findCmsBlocks({ key, value }) - /** - * Retrieve single cms block by key value - * - * @param context - * @param {any} key - * @param {any} value - * @param {any} excludeFields - * @param {any} includeFields - * @returns {Promise & Promise} - */ - single (context, { key = 'identifier', value, excludeFields = null, includeFields = null, skipCache = false }) { - const state = context.state - if (skipCache || (!state.items || state.items.length === 0)) { - let query = new SearchQuery() - if (value) { - query = query.applyFilter({key: key, value: {'like': value}}) - } - return quickSearchByQuery({ query, entityType: 'cms_block', excludeFields, includeFields }) - .then((resp) => { - context.commit(types.CMS_BLOCK_ADD_CMS_BLOCK, resp.items[0]) - return resp.items[0] - }) - .catch(err => { - Logger.error(err, 'cms')() - }) - } else { - return new Promise((resolve, reject) => { - if (state.items.length > 0) { - let cmsBlock = state.items.find((itm) => { return itm[key] === value }) - if (cmsBlock) { - resolve(cmsBlock) - } else { - reject(new Error('CMS block query returned empty result ' + key + ' = ' + value)) - } - } else { - resolve() - } + if (skipCache || cmsBlock.length === 0) { + const blockResponse = await quickSearchByQuery({ + query: createSingleBlockQuery({ key, value }), + entityType: 'cms_block', + excludeFields, + includeFields }) + + if (blockResponse.items.length > 0) { + commit(types.CMS_BLOCK_ADD_CMS_BLOCK, blockResponse.items[0]) + return blockResponse.items[0] + } } - }, + return cmsBlock[0] + }, addItem ({ commit }, block) { commit(types.CMS_BLOCK_ADD_CMS_BLOCK, block) } diff --git a/core/modules/cms/store/block/getters.ts b/core/modules/cms/store/block/getters.ts index e6072ecb2..c70f5333f 100644 --- a/core/modules/cms/store/block/getters.ts +++ b/core/modules/cms/store/block/getters.ts @@ -3,13 +3,18 @@ import CmsBlockState from '../../types/CmsBlockState' import RootState from '@vue-storefront/core/types/RootState' const getters: GetterTree = { - cmsBlocks: (state) => state.items, - cmsBlockIdentifier: (state) => (identifier) => { - return state.items.find(item => item.identifier === identifier) - }, - cmsBlockId: (state) => (id) => { - return state.items.find(item => item.id === id) - } + // @deprecated + cmsBlocks: (state, getters) => getters.getCmsBlocks, + // @deprecated + cmsBlockIdentifier: (state, getters) => (identifier) => getters.getCmsBlockByIdentifier(identifier), + // @deprecated + cmsBlockId: (state, getters) => (id) => getters.getCmsBlockById(id), + getCmsBlockByIdentifier: (state) => (identifier) => + state.items.find(item => typeof item === 'object' && item.identifier === identifier), + getCmsBlockById: (state) => (id) => state.items.find(item => item.id === id), + getCmsBlocks: (state) => state.items, + hasItems: (state) => state.items && state.items.length > 0, + findCmsBlocks: (state) => ({ key, value }) => state.items.filter(item => item[key] === value) } export default getters diff --git a/core/modules/cms/store/block/mutations.ts b/core/modules/cms/store/block/mutations.ts index e342022a5..4b15610d5 100644 --- a/core/modules/cms/store/block/mutations.ts +++ b/core/modules/cms/store/block/mutations.ts @@ -3,11 +3,6 @@ import * as types from './mutation-types' import CmsBlockState from '../../types/CmsBlockState' const mutations: MutationTree = { - /** - * Store CMS Blocks by identifier in state and localForage - * @param {} state - * @param {Array} cmsBlocks - */ [types.CMS_BLOCK_UPDATE_CMS_BLOCKS] (state, cmsBlocks) { state.items = cmsBlocks || [] }, diff --git a/core/modules/cms/store/cmsPersistPlugin.ts b/core/modules/cms/store/cmsPersistPlugin.ts new file mode 100644 index 000000000..562db10bf --- /dev/null +++ b/core/modules/cms/store/cmsPersistPlugin.ts @@ -0,0 +1,24 @@ +import * as pageTypes from './page/mutation-types' +import * as blockTypes from './block/mutation-types' +import { cmsPagesStorageKey } from './page' +import { cmsBlockStorageKey } from './block' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' +import { Logger } from '@vue-storefront/core/lib/logger' + +const cmsPersistPlugin = (mutation, state) => { + const cmsStorage = StorageManager.get('cms') + + if (mutation.type.startsWith(pageTypes.SN_CMS_PAGE)) { + cmsStorage.setItem(cmsPagesStorageKey, state.cmsPage.items).catch((reason) => { + Logger.error(reason, 'cms') // it doesn't work on SSR + }) + } + + if (mutation.type.startsWith(blockTypes.SN_CMS_BLOCK)) { + cmsStorage.setItem(cmsBlockStorageKey, state.cmsBlock.items).catch((reason) => { + Logger.error(reason, 'cms') // it doesn't work on SSR + }) + } +} + +export default cmsPersistPlugin diff --git a/core/modules/cms/store/hierarchy/actions.ts b/core/modules/cms/store/hierarchy/actions.ts index 2c2b3a044..741ca07de 100644 --- a/core/modules/cms/store/hierarchy/actions.ts +++ b/core/modules/cms/store/hierarchy/actions.ts @@ -1,30 +1,16 @@ import { ActionTree } from 'vuex' import { quickSearchByQuery } from '@vue-storefront/core/lib/search' -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' import RootState from '@vue-storefront/core/types/RootState'; import CmsHierarchyState from '../../types/CmsHierarchyState' -import { Logger } from '@vue-storefront/core/lib/logger' +import { createHierarchyLoadQuery } from '@vue-storefront/core/modules/cms/helpers' const actions: ActionTree = { - /** - * Retrieve cms hierarchy - * - * @param context - * @param {any} query - * @param {any} entityType - * @param {any} excludeFields - * @param {any} includeFields - * @returns {Promise & Promise} - */ list (context, {id, entityType = 'cms_hierarchy', excludeFields = null, includeFields = null}) { - let query = new SearchQuery() - - if (id) { - query = query.applyFilter({key: 'identifier', value: {'eq': id}}) - } - - return quickSearchByQuery({ query, entityType, excludeFields, includeFields }).catch(err => { - Logger.error(err, 'cms')() + return quickSearchByQuery({ + query: createHierarchyLoadQuery({ id }), + entityType, + excludeFields, + includeFields }) } } diff --git a/core/modules/cms/store/page/actions.ts b/core/modules/cms/store/page/actions.ts index 14f816003..f005d7b3b 100644 --- a/core/modules/cms/store/page/actions.ts +++ b/core/modules/cms/store/page/actions.ts @@ -1,99 +1,70 @@ import { ActionTree } from 'vuex' import { quickSearchByQuery } from '@vue-storefront/core/lib/search' import * as types from './mutation-types' -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' import RootState from '@vue-storefront/core/types/RootState'; import CmsPageState from '../../types/CmsPageState' -import { cacheStorage } from '../../' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' import { cmsPagesStorageKey } from './' -import { Logger } from '@vue-storefront/core/lib/logger' +import { createPageLoadingQuery, createSinglePageLoadQuery } from '@vue-storefront/core/modules/cms/helpers' const actions: ActionTree = { - - /** - * Retrieve cms pages - * - * @param context - * @param {any} filterValues - * @param {any} filterField - * @param {any} size - * @param {any} start - * @param {any} excludeFields - * @param {any} includeFields - * @returns {Promise & Promise} - */ async list ({ commit }, { filterValues = null, filterField = 'identifier', size = 150, start = 0, excludeFields = null, includeFields = null, skipCache = false }) { - let query = new SearchQuery() - if (filterValues) { - query = query.applyFilter({key: filterField, value: {'like': filterValues}}) - } - return quickSearchByQuery({ query, entityType: 'cms_page', excludeFields, includeFields }) - .then((resp) => { - commit(types.CMS_PAGE_UPDATE_CMS_PAGES, resp.items) - return resp.items - }) - .catch(err => { - Logger.error(err, 'cms')() - }) + let query = createPageLoadingQuery({ filterField, filterValues }) + const pageResponse = await quickSearchByQuery({ query, entityType: 'cms_page', excludeFields, includeFields }) + + commit(types.CMS_PAGE_UPDATE_CMS_PAGES, pageResponse.items) + return pageResponse.items }, + async single ({ getters, commit, dispatch }, { key = 'identifier', value, excludeFields = null, includeFields = null, skipCache = false, setCurrent = true }) { + const currentItems = getters.findItems({ key, value }) - /** - * Retrieve single cms page by key value - * - * @param context - * @param {any} key - * @param {any} value - * @param {any} excludeFields - * @param {any} includeFields - * @returns {Promise & Promise} - */ - single (context, { key = 'identifier', value, excludeFields = null, includeFields = null, skipCache = false, setCurrent = true }) { - let query = new SearchQuery() - if (value) { - query = query.applyFilter({key: key, value: { 'like': value }}) - } - if (skipCache || (!context.state.items || context.state.items.length === 0) || !context.state.items.find(p => p[key] === value)) { - return quickSearchByQuery({ query, entityType: 'cms_page', excludeFields, includeFields }) - .then((resp) => { - if (resp && resp.items && resp.items.length > 0) { - context.commit(types.CMS_PAGE_ADD_CMS_PAGE, resp.items[0]) - if (setCurrent) context.commit(types.CMS_PAGE_SET_CURRENT, resp.items[0]) - return resp.items[0] - } else { - throw new Error('CMS query returned empty result') - } - }) - .catch(err => { - throw err - }) - } else { - return new Promise((resolve, reject) => { - let resp = context.state.items.find(p => p[key] === value) - if (resp) { - if (setCurrent) context.commit(types.CMS_PAGE_SET_CURRENT, resp) - resolve(resp) - } else { - cacheStorage.getItem(cmsPagesStorageKey, (err, storedItems) => { - if (err) reject(err) - if (storedItems) { - context.commit(types.CMS_PAGE_UPDATE_CMS_PAGES, storedItems) - let resp = storedItems.find(p => p[key] === value) - if (!resp) reject(new Error('CMS query returned empty result')) - if (setCurrent) context.commit(types.CMS_PAGE_SET_CURRENT, resp) - resolve(resp) - } else { - reject(new Error('CMS query returned empty result')) - } - }) - } + if (skipCache || !getters.hasItems || !currentItems) { + const pageResponse = await quickSearchByQuery({ + query: createSinglePageLoadQuery({ key, value }), + entityType: 'cms_page', + excludeFields, + includeFields }) + + if (pageResponse && pageResponse.items && pageResponse.items.length > 0) { + commit(types.CMS_PAGE_ADD_CMS_PAGE, pageResponse.items[0]) + if (setCurrent) commit(types.CMS_PAGE_SET_CURRENT, pageResponse.items[0]) + return pageResponse.items[0] + } + + throw new Error('CMS query returned empty result') + } + + if (currentItems) { + if (setCurrent) { + commit(types.CMS_PAGE_SET_CURRENT, currentItems) + } + return currentItems } }, + async loadFromCache ({ commit }, { key, value, setCurrent }) { + const cmsStorage = StorageManager.get('cms') + const storedItems = await cmsStorage.getItem(cmsPagesStorageKey) + + if (storedItems) { + commit(types.CMS_PAGE_UPDATE_CMS_PAGES, storedItems) + const resp = storedItems.find(p => p[key] === value) + if (!resp) { + throw new Error('CMS query returned empty result') + } + + if (setCurrent) { + commit(types.CMS_PAGE_SET_CURRENT, resp) + } + return resp + } + + throw new Error('CMS query returned empty result') + }, addItem ({ commit }, page) { commit(types.CMS_PAGE_ADD_CMS_PAGE, page) } - } export default actions diff --git a/core/modules/cms/store/page/getters.ts b/core/modules/cms/store/page/getters.ts index 955fa36b1..767d693fa 100644 --- a/core/modules/cms/store/page/getters.ts +++ b/core/modules/cms/store/page/getters.ts @@ -1,9 +1,17 @@ import { GetterTree } from 'vuex' import RootState from '@vue-storefront/core/types/RootState' import CmsPageState from '../../types/CmsPageState' +import { Logger } from '@vue-storefront/core/lib/logger' const getters: GetterTree = { - cmsPages: (state) => state.items + cmsPages: (state, getters) => { + Logger.error('The getter cmsPage/cmsPages has been deprecated please change to cmsPage/getCmsPages')() + + return getters.getCmsPages + }, + getCmsPages: (state) => state.items, + hasItems: (state) => state.items && state.items.length > 0, + findItems: (state) => ({ key, value }) => state.items.find(p => p[key] === value) } export default getters diff --git a/core/modules/cms/store/page/mutations.ts b/core/modules/cms/store/page/mutations.ts index 512641728..008910efe 100644 --- a/core/modules/cms/store/page/mutations.ts +++ b/core/modules/cms/store/page/mutations.ts @@ -3,11 +3,6 @@ import * as types from './mutation-types' import CmsPageState from '../../types/CmsPageState' const mutations: MutationTree = { - /** - * Store CMS Pages by identifier in state and localForage - * @param {} state - * @param {Array} cmsPages - */ [types.CMS_PAGE_UPDATE_CMS_PAGES] (state, cmsPages) { state.items = cmsPages || [] }, diff --git a/core/modules/cms/store/plugin.ts b/core/modules/cms/store/plugin.ts deleted file mode 100644 index 0845919ae..000000000 --- a/core/modules/cms/store/plugin.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as pageTypes from './page/mutation-types' -import * as blockTypes from './block/mutation-types' -import { cmsPagesStorageKey } from './page' -import { cmsBlockStorageKey } from './block' -import { cacheStorage } from '../' -import { Logger } from '@vue-storefront/core/lib/logger' - -export function plugin (mutation, state) { - const type = mutation.type - - if (type.startsWith(pageTypes.SN_CMS_PAGE)) { // check if this mutation is pages related - cacheStorage.setItem(cmsPagesStorageKey, state.cmsPage.items).catch((reason) => { - Logger.error(reason, 'cms') // it doesn't work on SSR - }) - } - - if (type.startsWith(blockTypes.SN_CMS_BLOCK)) { // check if this mutation is block related - cacheStorage.setItem(cmsBlockStorageKey, state.cmsBlock.items).catch((reason) => { - Logger.error(reason, 'cms') // it doesn't work on SSR - }) - } -} diff --git a/core/modules/cms/test/unit/blockActions.spec.ts b/core/modules/cms/test/unit/blockActions.spec.ts new file mode 100644 index 000000000..3f6d78d42 --- /dev/null +++ b/core/modules/cms/test/unit/blockActions.spec.ts @@ -0,0 +1,131 @@ +import * as types from '../../store/block/mutation-types'; +import blockActions from '../../store/block/actions' + +import { quickSearchByQuery } from '@vue-storefront/core/lib/search' + +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('@vue-storefront/core/app', () => jest.fn()) +jest.mock('@vue-storefront/core/store', () => ({ Module: jest.fn() })) + +jest.mock('@vue-storefront/core/types/RootState') +jest.mock('@vue-storefront/core/lib/search') +jest.mock('@vue-storefront/core/modules/cms/helpers', () => ({ + createLoadingBlockQuery: jest.fn(), + createSingleBlockQuery: jest.fn() +})) + +describe('Block actions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('list method', () => { + it('should update block', async () => { + const contextMock = { + commit: jest.fn(), + getters: { hasItems: false } + } + const filter = {}; + const items = ['item1', 'item2', 'item3']; + + (quickSearchByQuery as any).mockResolvedValue({items}); + + const wrapper = (actions: any) => actions.list(contextMock, filter) + let listAction = await wrapper(blockActions) + + expect(quickSearchByQuery).toHaveBeenCalled() + expect(contextMock.commit).toBeCalledWith(types.CMS_BLOCK_UPDATE_CMS_BLOCKS, items) + expect(listAction).toBe(items) + }) + + it('should NOT update blocks if already cached', async () => { + const contextMock = { + commit: jest.fn(), + getters: { hasItems: true, getCmsBlocks: ['item1'] } + } + const filter = {}; + + const wrapper = (actions: any) => actions.list(contextMock, filter) + let listAction = await wrapper(blockActions) + + expect(quickSearchByQuery).not.toHaveBeenCalled() + expect(listAction).toEqual(['item1']) + }) + + it('should NOT update cms_blocks if cache is NOT skipped', async () => { + const contextMock = { + commit: jest.fn(), + getters: { hasItems: true, getCmsBlocks: ['item1'] } + } + const filter = { skipCache: false }; + + const wrapper = (actions: any) => actions.list(contextMock, filter) + let listAction = await wrapper(blockActions) + + expect(quickSearchByQuery).not.toHaveBeenCalled() + expect(listAction).toEqual(['item1']) + }) + }) + + describe('single method', () => { + it('should add single block if NOT found', async () => { + const contextMock = { + commit: jest.fn(), + getters: { findCmsBlocks: jest.fn(() => []) } + } + const filter = {}; + + (quickSearchByQuery as any).mockResolvedValue({items: ['item1', 'item2']}); + + const wrapper = (actions: any) => actions.single(contextMock, filter) + const singleAction = await wrapper(blockActions) + + expect(contextMock.commit).toBeCalledWith(types.CMS_BLOCK_ADD_CMS_BLOCK, 'item1') + expect(singleAction).toEqual('item1') + }) + + it('should add single block if cache is NOT skipped', async () => { + const contextMock = { + commit: jest.fn(), + getters: { findCmsBlocks: jest.fn(() => [{key: 'key1', value: 'val1'}]) } + } + const filter = { skipCache: true }; + + (quickSearchByQuery as any).mockResolvedValue({ items: ['item1', 'item2'] }); + + const wrapper = (actions: any) => actions.single(contextMock, filter) + const singleAction = await wrapper(blockActions) + + expect(contextMock.commit).toBeCalledWith(types.CMS_BLOCK_ADD_CMS_BLOCK, 'item1') + expect(singleAction).toEqual('item1') + }) + + it('should ONLY return block if found one', async () => { + const contextMock = { + commit: jest.fn(), + getters: { findCmsBlocks: jest.fn(() => [{ test: 'val1' }]) } + } + const filter = {key: 'test', value: 'val1'}; + + const wrapper = (actions: any) => actions.single(contextMock, filter) + const singleAction = await wrapper(blockActions) + + expect(contextMock.commit).not.toBeCalled() + expect(singleAction).toEqual({ test: 'val1' }) + }) + }) + + describe('addItem method', () => { + it('should add block', async () => { + const block = { query: {}, entityType: 'cms_block', excludeFields: [], includeFields: [] } + const contextMock = { + commit: jest.fn() + } + + const wrapper = (actions: any) => actions.addItem(contextMock, block) + await wrapper(blockActions) + + expect(contextMock.commit).toBeCalledWith(types.CMS_BLOCK_ADD_CMS_BLOCK, block) + }) + }) +}) diff --git a/core/modules/cms/test/unit/blockMutations.spec.ts b/core/modules/cms/test/unit/blockMutations.spec.ts new file mode 100644 index 000000000..ff5447fca --- /dev/null +++ b/core/modules/cms/test/unit/blockMutations.spec.ts @@ -0,0 +1,62 @@ +import * as types from '../../store/block/mutation-types' +import blockMutations from '../../store/block/mutations' + +jest.mock('@vue-storefront/core/app', () => jest.fn()) + +describe('Block mutations', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should update cms blocks', () => { + const cmsBlock = ['item1', 'item2', 'item3'] + const stateMock = { items: [] } + const expectedState = { + items: ['item1', 'item2', 'item3'] + } + + const wrapper = (mutations: any) => mutations[types.CMS_BLOCK_UPDATE_CMS_BLOCKS](stateMock, cmsBlock) + wrapper(blockMutations) + + expect(stateMock).toEqual(expectedState) + }) + + it('should clear cms blocks after update without args', () => { + const cmsBlock = ['item1', 'item2', 'item3'] + const stateMock = { items: [] } + const expectedState = { + items: [] + } + + const wrapper = (mutations: any) => mutations[types.CMS_BLOCK_UPDATE_CMS_BLOCKS](stateMock, false) + wrapper(blockMutations) + + expect(stateMock).toEqual(expectedState) + }) + + it('should add new item if item with the same id does NOT exist', () => { + const cmsBlock = { id: 2, data: 'item-id2-data' } + const stateMock = { items: [{ id: 1, data: 'item-id1-data' }] } + const expectedState = { + items: [{ id: 1, data: 'item-id1-data' }, { id: 2, data: 'item-id2-data' }] + } + + const wrapper = (mutations: any) => mutations[types.CMS_BLOCK_ADD_CMS_BLOCK](stateMock, cmsBlock) + wrapper(blockMutations) + expect(stateMock).toEqual(expectedState) + }) + + it('should NOT add new item if item with the same id exists', () => { + const cmsBlock = { id: 1, data: 'item-id1-new-data' } + const stateMock = { + items: [{ id: 1, data: 'item-id1-data' }] + } + const expectedState = { + items: [{ id: 1, data: 'item-id1-data' }] + } + + const wrapper = (mutations: any) => mutations[types.CMS_BLOCK_ADD_CMS_BLOCK](stateMock, cmsBlock) + wrapper(blockMutations) + expect(stateMock).toEqual(expectedState) + }) +}) diff --git a/core/modules/cms/test/unit/createHierarchyLoadQuery.spec.ts b/core/modules/cms/test/unit/createHierarchyLoadQuery.spec.ts new file mode 100644 index 000000000..09b7495cc --- /dev/null +++ b/core/modules/cms/test/unit/createHierarchyLoadQuery.spec.ts @@ -0,0 +1,25 @@ +import createHierarchyLoadQuery from '../../helpers/createHierarchyLoadQuery' + +describe('createHierarchyLoadQuery', () => { + it('should return hierarchic load query obj with applied filters if id is provided', () => { + const filter = { id: 3 } + let loadQuery = createHierarchyLoadQuery(filter) + + expect(loadQuery).toHaveProperty('_availableFilters') + expect(loadQuery).toHaveProperty('_searchText') + + let [ appliedFilter ] = loadQuery._appliedFilters + + expect(appliedFilter).toHaveProperty('value', { eq: filter.id }) + expect(appliedFilter).toHaveProperty('attribute', 'identifier') + expect(appliedFilter).toHaveProperty('scope') + expect(appliedFilter).toHaveProperty('options') + }) + + it('should return load query obj with base hierarchy if id is not provided', () => { + const filter = { id: null } + let hierarchyLoadQuery = createHierarchyLoadQuery(filter) + + expect(hierarchyLoadQuery).toEqual({ _availableFilters: [], _appliedFilters: [], _searchText: '' }) + }) +}) diff --git a/core/modules/cms/test/unit/createLoadingBlockQuery.spec.ts b/core/modules/cms/test/unit/createLoadingBlockQuery.spec.ts new file mode 100644 index 000000000..ceb4edb14 --- /dev/null +++ b/core/modules/cms/test/unit/createLoadingBlockQuery.spec.ts @@ -0,0 +1,20 @@ +import createLoadingBlockQuery from '../../helpers/createLoadingBlockQuery' + +describe('createLoadingBlockQuery', () => { + it('should return loading block query with applied proper filters', () => { + const filter = { filterField: 'test', filterValues: ['test1', 'test2'] } + + let loadingBlockQuery = createLoadingBlockQuery(filter) + let [ appliedFilter ] = loadingBlockQuery._appliedFilters + + expect(appliedFilter).toHaveProperty('attribute', filter.filterField) + expect(appliedFilter).toHaveProperty('value', { like: filter.filterValues }) + }) + + it('should return loading block query object with base hierarchy if filter values are not provided', () => { + const filter = { filterField: 'test', filterValues: undefined } + let loadingBlockQuery = createLoadingBlockQuery(filter) + + expect(loadingBlockQuery).toEqual({ _availableFilters: [], _appliedFilters: [], _searchText: '' }) + }) +}) diff --git a/core/modules/cms/test/unit/createPageLoadingQuery.spec.ts b/core/modules/cms/test/unit/createPageLoadingQuery.spec.ts new file mode 100644 index 000000000..290e9fb96 --- /dev/null +++ b/core/modules/cms/test/unit/createPageLoadingQuery.spec.ts @@ -0,0 +1,20 @@ +import createPageLoadingQuery from '../../helpers/createPageLoadingQuery' + +describe('createPageLoadingQuery', () => { + it('should return page loading query with applied proper filters', () => { + const filter = { filterField: 'test', filterValues: ['test1', 'test2'] } + + let pageLoadingQuery = createPageLoadingQuery(filter) + let [ appliedFilter ] = pageLoadingQuery._appliedFilters + + expect(appliedFilter).toHaveProperty('attribute', filter.filterField) + expect(appliedFilter).toHaveProperty('value', { like: filter.filterValues }) + }) + + it('should return page loading query with base hierarchy if filter values are not provided', () => { + const filter = { filterField: 'test', filterValues: undefined } + let pageLoadingQuery = createPageLoadingQuery(filter) + + expect(pageLoadingQuery).toEqual({ _availableFilters: [], _appliedFilters: [], _searchText: '' }) + }) +}) diff --git a/core/modules/cms/test/unit/createSingleBlockQuery.spec.ts b/core/modules/cms/test/unit/createSingleBlockQuery.spec.ts new file mode 100644 index 000000000..8409621e2 --- /dev/null +++ b/core/modules/cms/test/unit/createSingleBlockQuery.spec.ts @@ -0,0 +1,20 @@ +import createSingleBlockQuery from '../../helpers/createSingleBlockQuery' + +describe('createSingleBlockLoadQuery should', () => { + it('return single block load query with applied proper filters', () => { + const argsMock = { key: 'test', value: ['test1', 'test2'] } + + let mockSingleBlockQuery = createSingleBlockQuery(argsMock) + let [ appliedFilter ] = mockSingleBlockQuery._appliedFilters + + expect(appliedFilter).toHaveProperty('attribute', argsMock.key) + expect(appliedFilter).toHaveProperty('value', { like: argsMock.value }) + }) + + it('return create single block load query with base hierarchy if value is not provided', () => { + const argsMock = { key: 'test', value: undefined } + let mockSingleBlockQuery = createSingleBlockQuery(argsMock) + + expect(mockSingleBlockQuery).toEqual({ _availableFilters: [], _appliedFilters: [], _searchText: '' }) + }) +}) diff --git a/core/modules/cms/test/unit/createSinglePageLoadQuery.spec.ts b/core/modules/cms/test/unit/createSinglePageLoadQuery.spec.ts new file mode 100644 index 000000000..ab518b7d9 --- /dev/null +++ b/core/modules/cms/test/unit/createSinglePageLoadQuery.spec.ts @@ -0,0 +1,20 @@ +import createSinglePageLoadQuery from '../../helpers/createSinglePageLoadQuery' + +describe('createSinglePageLoadQuery should', () => { + it('return page loading query with applied proper filters', () => { + const filter = { key: 'test', value: ['test1', 'test2'] } + + let singlePageMockQuery = createSinglePageLoadQuery(filter) + let [ appliedFilter ] = singlePageMockQuery._appliedFilters + + expect(appliedFilter).toHaveProperty('attribute', filter.key) + expect(appliedFilter).toHaveProperty('value', { like: filter.value }) + }) + + it('return page loading query with base hierarchy if value is not provided', () => { + const filter = { key: 'test', value: undefined } + let singlePageMockQuery = createSinglePageLoadQuery(filter) + + expect(singlePageMockQuery).toEqual({ _availableFilters: [], _appliedFilters: [], _searchText: '' }) + }) +}) diff --git a/core/modules/cms/test/unit/hierarchyActions.spec.ts b/core/modules/cms/test/unit/hierarchyActions.spec.ts new file mode 100644 index 000000000..edbd8893d --- /dev/null +++ b/core/modules/cms/test/unit/hierarchyActions.spec.ts @@ -0,0 +1,24 @@ +import hierarchyActions from '../../store/hierarchy/actions' +import { quickSearchByQuery } from '@vue-storefront/core/lib/search' + +jest.mock('@vue-storefront/core/lib/search') + +jest.mock('@vue-storefront/core/app', () => jest.fn()) +jest.mock('@vue-storefront/core/store', () => ({ Module: jest.fn() })) +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); + +describe('Hierarchy actions', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should list hierarchy', async () => { + const contextMock = {}; + const filter = {id: 1, entityType: 'cms_hierarchy', excludeFields: null, includeFields: null} + + const wrapper = (actions: any) => actions.list(contextMock, filter); + const listAction = await wrapper(hierarchyActions) + + expect(quickSearchByQuery).toHaveBeenCalled() + }) +}) diff --git a/core/modules/cms/test/unit/pageActions.spec.ts b/core/modules/cms/test/unit/pageActions.spec.ts new file mode 100644 index 000000000..9273039c9 --- /dev/null +++ b/core/modules/cms/test/unit/pageActions.spec.ts @@ -0,0 +1,247 @@ +import * as types from '../../store/page/mutation-types'; +import pageActions from '../../store/page/actions' + +import { StorageManager } from '@vue-storefront/core/lib/storage-manager'; +import { quickSearchByQuery } from '@vue-storefront/core/lib/search' + +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('@vue-storefront/core/app', () => jest.fn()) +jest.mock('@vue-storefront/core/store', () => ({ Module: jest.fn() })) + +jest.mock('@vue-storefront/core/types/RootState') +jest.mock('@vue-storefront/core/lib/search') +jest.mock('@vue-storefront/core/lib/storage-manager') +jest.mock('@vue-storefront/core/modules/cms/helpers', () => ({ + createSinglePageLoadQuery: jest.fn(), + createPageLoadingQuery: jest.fn() +})) + +describe('Page actions', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('list method', () => { + it('should update pages and list them', async () => { + const filter = {} + const items = ['item1, item2, item3'] + const contextMock = { + commit: jest.fn() + }; + + (quickSearchByQuery as any).mockResolvedValue({items: items}) + + const wrapper = (actions: any) => actions.list(contextMock, filter) + const listAction = await wrapper(pageActions) + + expect(contextMock.commit).toBeCalledWith(types.CMS_PAGE_UPDATE_CMS_PAGES, items) + expect(listAction).toEqual(items) + }) + }) + + describe('single method', () => { + it('should add page if cache is skipped', async () => { + const filter = { skipCache: true, setCurrent: false } + const contextMock = { + getters: { + findItems: () => 'item1', + hasItems: true + }, + commit: jest.fn(), + dispatch: jest.fn() + }; + + (quickSearchByQuery as any).mockResolvedValue({items: ['item1']}) + + const wrapper = (actions: any) => actions.single(contextMock, filter) + const singleAction = await wrapper(pageActions) + + expect(contextMock.commit).toBeCalledWith(types.CMS_PAGE_ADD_CMS_PAGE, 'item1') + expect(singleAction).toEqual('item1') + }) + + it('should add page if cache is skipped and set as current', async () => { + const filter = { skipCache: true, setCurrent: true } + const contextMock = { + getters: { + findItems: () => 'item1', + hasItems: true + }, + commit: jest.fn(), + dispatch: jest.fn() + }; + + (quickSearchByQuery as any).mockResolvedValue({items: ['item1']}) + + const wrapper = (actions: any) => actions.single(contextMock, filter) + const singleAction = await wrapper(pageActions) + + expect(contextMock.commit).toBeCalledWith(types.CMS_PAGE_ADD_CMS_PAGE, 'item1') + expect(contextMock.commit).toBeCalledWith(types.CMS_PAGE_SET_CURRENT, 'item1') + expect(singleAction).toEqual('item1') + }) + + it('should add page if does NOT have items', async () => { + const filter = {} + const contextMock = { + getters: { + findItems: () => 'item1', + hasItems: false + }, + commit: jest.fn(), + dispatch: jest.fn() + }; + + (quickSearchByQuery as any).mockResolvedValue({items: ['item1']}) + + const wrapper = (actions: any) => actions.single(contextMock, filter) + const singleAction = await wrapper(pageActions) + + expect(contextMock.commit).toBeCalledWith(types.CMS_PAGE_ADD_CMS_PAGE, 'item1') + expect(singleAction).toEqual('item1') + }) + + it('should add page if does NOT have current items', async () => { + const filter = {} + const contextMock = { + getters: { + findItems: () => undefined, + hasItems: false + }, + commit: jest.fn(), + dispatch: jest.fn() + }; + + (quickSearchByQuery as any).mockResolvedValue({items: ['item1']}) + + const wrapper = (actions: any) => actions.single(contextMock, filter) + const singleAction = await wrapper(pageActions) + + expect(contextMock.commit).toBeCalledWith(types.CMS_PAGE_ADD_CMS_PAGE, 'item1') + expect(singleAction).toEqual('item1') + }) + + it('should throw error if query returned empty value', async () => { + const filter = {} + const contextMock = { + getters: { + findItems: () => undefined, + hasItems: false + }, + commit: jest.fn(), + dispatch: jest.fn() + }; + + (quickSearchByQuery as any).mockResolvedValue({items: ['item1']}) + + const wrapper = (actions: any) => actions.single(contextMock, filter) + try { + await wrapper(pageActions) + } catch (e) { + expect(e.message).toBe('CMS query returned empty result') + } + }) + + it('should NOT add new page but set current one', async () => { + const filter = {} + const contextMock = { + getters: { + findItems: () => 'item1', + hasItems: false + }, + commit: jest.fn(), + dispatch: jest.fn() + }; + + const wrapper = (actions: any) => actions.single(contextMock, filter) + const singleAction = await wrapper(pageActions) + + expect(contextMock.commit).toBeCalledWith(types.CMS_PAGE_SET_CURRENT, 'item1') + expect(singleAction).toEqual('item1') + }) + }) + + describe('loadFromCache action', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return cached response and set the page as current', async () => { + const filter = {key: 'test', value: 'value', setCurrent: true} + const contextMock = { commit: jest.fn() } + const wrapper = (actions: any) => actions.loadFromCache(contextMock, filter); + (StorageManager as any).get.mockImplementationOnce((...args) => { + return { + getItem: (...args) => Promise.resolve([{test: 'value'}]) + } + }) + + const loadFromCacheAction = await wrapper(pageActions) + + expect(contextMock.commit).toHaveBeenCalledWith(types.CMS_PAGE_SET_CURRENT, {test: 'value'}) + expect(loadFromCacheAction).toEqual({test: 'value'}) + }) + + it('should return cached response and NOT set the page as current', async () => { + const filter = {key: 'test', value: 'value', setCurrent: false} + const contextMock = { commit: jest.fn() } + const wrapper = (actions: any) => actions.loadFromCache(contextMock, filter); + (StorageManager as any).get.mockImplementationOnce((...args) => { + return { + getItem: (...args: any) => Promise.resolve([{ test: 'value' }]) + } + }) + + const loadFromCacheAction = await wrapper(pageActions) + + expect(contextMock.commit).not.toHaveBeenCalledWith(types.CMS_PAGE_SET_CURRENT, {test: 'value'}) + expect(loadFromCacheAction).toEqual({test: 'value'}) + }) + + it('should throw error when storedItems are empty', async () => { + const filter = {key: 'test', value: 'value', setCurrent: false} + const contextMock = { commit: jest.fn() } + const wrapper = (actions: any) => actions.loadFromCache(contextMock, filter); + (StorageManager as any).get.mockImplementationOnce((...args: any) => { + return ({getItem: (...args: any) => Promise.resolve(undefined)}) + }) + + try { + await wrapper(pageActions) + } catch (e) { + expect(e.message).toBe('CMS query returned empty result') + } + }) + }) + + describe('loadFromCache action', () => { + it('should throw error when cannot find given element in stored items', async () => { + const filter = {key: 'test', value: 'value', setCurrent: false} + const contextMock = { commit: jest.fn() } + const wrapper = (actions: any) => actions.loadFromCache(contextMock, filter); + (StorageManager as any).get.mockImplementationOnce((...args: any) => { + return ({getItem: (...args: any) => Promise.resolve([])}) + }) + + try { + await wrapper(pageActions) + } catch (e) { + expect(contextMock.commit).toBeCalledWith(types.CMS_PAGE_UPDATE_CMS_PAGES, []) + expect(e.message).toBe('CMS query returned empty result') + } + }) + }) + + describe('addItem method', () => { + it('should add new page', async () => { + const page = 'page_name' + const contextMock = { + commit: jest.fn() + } + const wrapper = (actions: any) => actions.addItem(contextMock, page) + await wrapper(pageActions) + + expect(contextMock.commit).toBeCalledWith(types.CMS_PAGE_ADD_CMS_PAGE, page) + }) + }) +}) diff --git a/core/modules/cms/test/unit/pageMutation.spec.ts b/core/modules/cms/test/unit/pageMutation.spec.ts new file mode 100644 index 000000000..00a4c7a1b --- /dev/null +++ b/core/modules/cms/test/unit/pageMutation.spec.ts @@ -0,0 +1,70 @@ +import * as types from '../../store/page/mutation-types' +import pageMutations from '../../store/page/mutations' + +jest.mock('@vue-storefront/core/app', () => jest.fn()) + +describe('Page mutations', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should update cms pages', () => { + const cmsPage = ['new-page1', 'new-page2'] + const stateMock = { items: [] } + const expectedState = { + items: ['new-page1', 'new-page2'] + } + + const wrapper = (mutations: any) => mutations[types.CMS_PAGE_UPDATE_CMS_PAGES](stateMock, cmsPage) + wrapper(pageMutations) + + expect(stateMock).toEqual(expectedState) + }) + + it('should clear cms pages after update without args', () => { + const stateMock = { items: ['new-page1', 'new-page2'] } + const expectedState = { items: [] } + + const wrapper = (mutations: any) => mutations[types.CMS_PAGE_UPDATE_CMS_PAGES](stateMock, false) + wrapper(pageMutations) + + expect(stateMock).toEqual(expectedState) + }) + + it('should set page to current', () => { + const current = { id: 2, url: 'new-page' } + const stateMock = { current: { id: 1, url: 'old-page' } } + const expectedState = { current: { id: 2, url: 'new-page' } } + + const wrapper = (mutations: any) => mutations[types.CMS_PAGE_SET_CURRENT](stateMock, current) + wrapper(pageMutations) + + expect(stateMock).toEqual(expectedState) + }) + + it('should add new page if page with the same id does NOT exist', () => { + const cmsPage = { id: 2, url: 'new-page' } + const stateMock = { items: [{ id: 1, url: 'old-page' }] } + const expectedState = { + items: [ { id: 1, url: 'old-page' }, { id: 2, url: 'new-page' } ] + } + + const wrapper = (mutations: any) => mutations[types.CMS_PAGE_ADD_CMS_PAGE](stateMock, cmsPage) + wrapper(pageMutations) + + expect(stateMock).toEqual(expectedState) + }) + + it('should NOT add new page if page with the same id exists', () => { + const cmsPage = { id: 1, url: 'new-page' } + const stateMock = { items: [{ id: 1, url: 'old-page' }] } + const expectedState = { + items: [ { id: 1, url: 'old-page' } ] + } + + const wrapper = (mutations: any) => mutations[types.CMS_PAGE_ADD_CMS_PAGE](stateMock, cmsPage) + wrapper(pageMutations) + + expect(stateMock).toEqual(expectedState) + }) +}) diff --git a/core/modules/compare/components/AddToCompare.ts b/core/modules/compare/components/AddToCompare.ts index 5823da951..abdddc498 100644 --- a/core/modules/compare/components/AddToCompare.ts +++ b/core/modules/compare/components/AddToCompare.ts @@ -1,9 +1,14 @@ import Product from '@vue-storefront/core/modules/catalog/types/Product' +import { CompareModule } from '../' import compareMountedMixin from '@vue-storefront/core/modules/compare/mixins/compareMountedMixin' +import { registerModule } from '@vue-storefront/core/lib/modules'; export const AddToCompare = { name: 'AddToCompare', mixins: [compareMountedMixin], + created () { + registerModule(CompareModule) + }, methods: { addToCompare (product: Product) { return this.$store.state['compare'] diff --git a/core/modules/compare/components/Compare.ts b/core/modules/compare/components/Compare.ts index 04f55e029..c2782fcc8 100644 --- a/core/modules/compare/components/Compare.ts +++ b/core/modules/compare/components/Compare.ts @@ -1,3 +1,4 @@ +import { mapGetters } from 'vuex' import Product from '@vue-storefront/core/modules/catalog/types/Product' import compareMountedMixin from '@vue-storefront/core/modules/compare/mixins/compareMountedMixin' @@ -5,17 +6,14 @@ export const Compare = { name: 'Compare', mixins: [compareMountedMixin], computed: { - items (): Product[] { - return this.$store.state.compare.items - }, - allComparableAttributes () { - const attributesByCode = this.$store.getters['attribute/attributeListByCode'] - return Object.values(attributesByCode).filter((a: any) => parseInt(a.is_comparable)) - } + ...mapGetters({ + items: 'compare/getCompareItems', + allComparableAttributes: 'attribute/getAllComparableAttributes' + }) }, created () { this.$store.dispatch('attribute/list', { - filterValues: [true], + filterValues: [], filterField: 'is_user_defined' }) }, diff --git a/core/modules/compare/components/IsOnCompare.ts b/core/modules/compare/components/IsOnCompare.ts new file mode 100644 index 000000000..8f0f3ec41 --- /dev/null +++ b/core/modules/compare/components/IsOnCompare.ts @@ -0,0 +1,22 @@ +import { CompareModule } from '..' +import compareMountedMixin from '@vue-storefront/core/modules/compare/mixins/compareMountedMixin' +import { registerModule } from '@vue-storefront/core/lib/modules'; + +export const IsOnCompare = { + name: 'IsOnCompare', + mixins: [compareMountedMixin], + props: { + product: { + required: true, + type: Object + } + }, + created () { + registerModule(CompareModule) + }, + computed: { + isOnCompare () { + return this.$store.getters['compare/isOnCompare'](this.product) + } + } +} diff --git a/core/modules/compare/components/RemoveFromCompare.ts b/core/modules/compare/components/RemoveFromCompare.ts new file mode 100644 index 000000000..4b737e23d --- /dev/null +++ b/core/modules/compare/components/RemoveFromCompare.ts @@ -0,0 +1,15 @@ +import Product from '@vue-storefront/core/modules/catalog/types/Product'; +import { CompareModule } from '..'; +import compareMountedMixin from '@vue-storefront/core/modules/compare/mixins/compareMountedMixin'; +import { registerModule } from '@vue-storefront/core/lib/modules'; + +export const RemoveFromCompare = { + name: 'RemoveFromCompare', + mixins: [compareMountedMixin], + methods: { + removeFromCompare (product: Product) { + registerModule(CompareModule) + this.$store.dispatch('compare/removeItem', product); + } + } +}; diff --git a/core/modules/compare/hooks/afterRegistration.ts b/core/modules/compare/hooks/afterRegistration.ts deleted file mode 100644 index d95a368d4..000000000 --- a/core/modules/compare/hooks/afterRegistration.ts +++ /dev/null @@ -1,2 +0,0 @@ -export function afterRegistration ({ Vue, config, store, isServer }) { -} diff --git a/core/modules/compare/hooks/beforeRegistration.ts b/core/modules/compare/hooks/beforeRegistration.ts deleted file mode 100644 index 291d71a47..000000000 --- a/core/modules/compare/hooks/beforeRegistration.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as localForage from 'localforage' -import UniversalStorage from '@vue-storefront/core/store/lib/storage' -import { currentStoreView } from '@vue-storefront/core/lib/multistore' - -export function beforeRegistration ({ Vue, config, store, isServer }) { - const storeView = currentStoreView() - const dbNamePrefix = storeView.storeCode ? storeView.storeCode + '-' : '' - - Vue.prototype.$db.compareCollection = new UniversalStorage(localForage.createInstance({ - name: dbNamePrefix + 'shop', - storeName: 'compare', - driver: localForage[config.localForage.defaultDrivers['compare']] - })) -} diff --git a/core/modules/compare/index.ts b/core/modules/compare/index.ts index e974ceda4..6a4ae6165 100644 --- a/core/modules/compare/index.ts +++ b/core/modules/compare/index.ts @@ -1,15 +1,11 @@ -import { module } from './store' -import { createModule } from '@vue-storefront/core/lib/module' -import { beforeRegistration } from './hooks/beforeRegistration' -import { afterRegistration } from './hooks/afterRegistration' -import { initCacheStorage } from '@vue-storefront/core/helpers/initCacheStorage'; -import { plugin } from './store/plugin' -export const KEY = 'compare' -export const cacheStorage = initCacheStorage(KEY) -export const Compare = createModule({ - key: KEY, - store: { modules: [{ key: KEY, module }], plugin }, - beforeRegistration, - afterRegistration -}) +import { compareStore } from './store' +import cachePersistPlugin from './store/plugin' +import { StorefrontModule } from '@vue-storefront/core/lib/modules'; +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' + +export const CompareModule: StorefrontModule = function ({store}) { + StorageManager.init('compare') + store.registerModule('compare', compareStore) + store.subscribe(cachePersistPlugin) +} diff --git a/core/modules/compare/store/actions.ts b/core/modules/compare/store/actions.ts index 40df658be..b9b147788 100644 --- a/core/modules/compare/store/actions.ts +++ b/core/modules/compare/store/actions.ts @@ -1,40 +1,32 @@ -import Vue from 'vue' import { ActionTree } from 'vuex' import * as types from './mutation-types' -import { htmlDecode } from '@vue-storefront/core/store/lib/filters' -import i18n from '@vue-storefront/i18n' -import rootStore from '@vue-storefront/core/store' import RootState from '@vue-storefront/core/types/RootState' import CompareState from '../types/CompareState' -import { cacheStorage } from '../' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' import { Logger } from '@vue-storefront/core/lib/logger' + const actions: ActionTree = { - load ({ commit, getters }, force: boolean = false) { + async load ({ commit, getters, dispatch }, force: boolean = false) { if (!force && getters.isCompareLoaded) return commit(types.SET_COMPARE_LOADED) - cacheStorage.getItem('current-compare', (err, storedItems) => { - if (err) throw new Error(err) + const storedItems = await dispatch('fetchCurrentCompare') + + if (storedItems) { commit(types.COMPARE_LOAD_COMPARE, storedItems) Logger.info('Compare state loaded from browser cache: ', 'cache', storedItems)() - }) + } + }, + async fetchCurrentCompare () { + const cacheStorage = StorageManager.get('compare') + return cacheStorage.getItem('current-compare') }, - addItem ({commit}, product) { - commit(types.COMPARE_ADD_ITEM, {product}) - rootStore.dispatch('notification/spawnNotification', { - type: 'success', - message: i18n.t('Product {productName} has been added to the compare!', { productName: htmlDecode(product.name) }), - action1: { label: i18n.t('OK') } - }) + async addItem ({ commit }, product) { + commit(types.COMPARE_ADD_ITEM, { product }) }, - removeItem ({commit}, product) { - commit(types.COMPARE_DEL_ITEM, {product}) - rootStore.dispatch('notification/spawnNotification', { - type: 'success', - message: i18n.t('Product {productName} has been removed from compare!', { productName: htmlDecode(product.name) }), - action1: { label: i18n.t('OK') } - }) + async removeItem ({ commit }, product) { + commit(types.COMPARE_DEL_ITEM, { product }) }, - clear ({commit}) { + async clear ({commit}) { commit(types.COMPARE_LOAD_COMPARE, []) } } diff --git a/core/modules/compare/store/getters.ts b/core/modules/compare/store/getters.ts index c5aeb19d8..8483a3ace 100644 --- a/core/modules/compare/store/getters.ts +++ b/core/modules/compare/store/getters.ts @@ -3,9 +3,11 @@ import RootState from '@vue-storefront/core/types/RootState' import CompareState from '../types/CompareState' const getters: GetterTree = { - isEmpty: (state) => state.items.length === 0, - isOnCompare: (state) => (product) => state.items.find(p => p.sku === product.sku), - isCompareLoaded: state => state.loaded + isEmpty: state => state.items.length === 0, + isOnCompare: state => product => state.items.some(p => p.sku === product.sku), + isCompareLoaded: state => state.loaded, + getCompareProductsCount: state => state.items.length, + getCompareItems: state => state.items } export default getters diff --git a/core/modules/compare/store/index.ts b/core/modules/compare/store/index.ts index 5d71458e0..58f971479 100644 --- a/core/modules/compare/store/index.ts +++ b/core/modules/compare/store/index.ts @@ -5,7 +5,7 @@ import mutations from './mutations' import RootState from '@vue-storefront/core/types/RootState' import CompareState from '../types/CompareState' -export const module: Module = { +export const compareStore: Module = { namespaced: true, state: { loaded: false, diff --git a/core/modules/compare/store/mutation-types.ts b/core/modules/compare/store/mutation-types.ts index e149cfd1f..6be6dabc8 100644 --- a/core/modules/compare/store/mutation-types.ts +++ b/core/modules/compare/store/mutation-types.ts @@ -1,5 +1,5 @@ export const SN_COMPARE = 'compare' -export const COMPARE_ADD_ITEM = SN_COMPARE + '/ADD' -export const COMPARE_DEL_ITEM = SN_COMPARE + '/DEL' -export const COMPARE_LOAD_COMPARE = SN_COMPARE + '/LOAD' +export const COMPARE_ADD_ITEM = `${SN_COMPARE}/ADD` +export const COMPARE_DEL_ITEM = `${SN_COMPARE}/DEL` +export const COMPARE_LOAD_COMPARE = `${SN_COMPARE}/LOAD` export const SET_COMPARE_LOADED = `${SN_COMPARE}/SET_COMPARE_LOADED` diff --git a/core/modules/compare/store/mutations.ts b/core/modules/compare/store/mutations.ts index 771feb2c3..3b2c0c813 100644 --- a/core/modules/compare/store/mutations.ts +++ b/core/modules/compare/store/mutations.ts @@ -10,9 +10,7 @@ const mutations: MutationTree = { [types.COMPARE_ADD_ITEM] (state, {product}) { const record = state.items.find(p => p.sku === product.sku) if (!record) { - state.items.push({ - ...product - }) + state.items.push(product) } }, [types.COMPARE_DEL_ITEM] (state, {product}) { diff --git a/core/modules/compare/store/plugin.ts b/core/modules/compare/store/plugin.ts index 46a2ac434..d6ab6359c 100644 --- a/core/modules/compare/store/plugin.ts +++ b/core/modules/compare/store/plugin.ts @@ -1,13 +1,17 @@ import * as types from './mutation-types' -import { cacheStorage } from '../' import { Logger } from '@vue-storefront/core/lib/logger' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' -export function plugin (mutation, state) { - const type = mutation.type +const watchedMutations = [types.COMPARE_ADD_ITEM, types.COMPARE_DEL_ITEM, types.COMPARE_LOAD_COMPARE].map(m => `compare/${m}`) - if ([types.COMPARE_ADD_ITEM, types.COMPARE_DEL_ITEM, types.COMPARE_LOAD_COMPARE].includes(type)) { // check if this mutation is comapre related +const cachePersistPlugin = (mutation, state) => { + const cacheStorage = StorageManager.get('compare') + + if (watchedMutations.includes(mutation.type)) { cacheStorage.setItem('current-compare', state.compare.items).catch((reason) => { Logger.error(reason, 'compare') }) } } + +export default cachePersistPlugin diff --git a/core/modules/compare/test/unit/components/AddToCompare.spec.ts b/core/modules/compare/test/unit/components/AddToCompare.spec.ts new file mode 100644 index 000000000..64c5cd77c --- /dev/null +++ b/core/modules/compare/test/unit/components/AddToCompare.spec.ts @@ -0,0 +1,42 @@ +import { mountMixinWithStore } from '@vue-storefront/unit-tests/utils'; +import { AddToCompare } from '../../../components/AddToCompare' +import { registerModule } from '@vue-storefront/core/lib/modules'; +import { CompareModule } from '@vue-storefront/core/modules/compare'; + +jest.mock('@vue-storefront/core/helpers', () => ({ + once: jest.fn() +})); +jest.mock('@vue-storefront/core/modules/compare/mixins/compareMountedMixin', () => ({})) +jest.mock('@vue-storefront/core/lib/modules', () => ({ + registerModule: jest.fn() +})) +jest.mock('@vue-storefront/core/modules/compare', () => ({})) + +describe('AddToCompare', () => { + it('addToCompare dispatches addItem action', () => { + const product = {}; + + const storeMock = { + modules: { + compare: { + actions: { + addItem: jest.fn(() => []) + }, + namespaced: true + } + } + }; + + const wrapper = mountMixinWithStore(AddToCompare, storeMock); + + (wrapper.vm as any).addToCompare(product); + + expect(storeMock.modules.compare.actions.addItem).toBeCalledWith(expect.anything(), product, undefined); + }) + + it('compare module has been registered on created', () => { + mountMixinWithStore(AddToCompare); + + expect(registerModule).toBeCalledWith(CompareModule); + }) +}); diff --git a/core/modules/compare/test/unit/components/Compare.spec.ts b/core/modules/compare/test/unit/components/Compare.spec.ts new file mode 100644 index 000000000..3c1462f51 --- /dev/null +++ b/core/modules/compare/test/unit/components/Compare.spec.ts @@ -0,0 +1,56 @@ +import { mountMixinWithStore } from '@vue-storefront/unit-tests/utils'; +import { Compare } from '../../../components/Compare' + +jest.mock('@vue-storefront/core/helpers', () => ({ + once: jest.fn() +})); +jest.mock('@vue-storefront/core/modules/compare/mixins/compareMountedMixin', () => ({})) + +describe('Compare', () => { + it('Compare dispatches attribute list action on created', () => { + const storeMock = { + modules: { + attribute: { + actions: { + list: jest.fn(() => []) + }, + namespaced: true + } + } + }; + + mountMixinWithStore(Compare, storeMock); + + expect(storeMock.modules.attribute.actions.list).toBeCalledWith(expect.anything(), { + filterValues: [], + filterField: 'is_user_defined' + }, undefined); + }) + + it('removeFromCompare dispatches addItem action', () => { + const product = {}; + + const storeMock = { + modules: { + compare: { + actions: { + removeItem: jest.fn() + }, + namespaced: true + }, + attribute: { + actions: { + list: jest.fn(() => []) + }, + namespaced: true + } + } + }; + + const wrapper = mountMixinWithStore(Compare, storeMock); + + (wrapper.vm as any).removeFromCompare(product); + + expect(storeMock.modules.compare.actions.removeItem).toBeCalledWith(expect.anything(), product, undefined); + }) +}); diff --git a/core/modules/compare/test/unit/components/Product.spec.ts b/core/modules/compare/test/unit/components/Product.spec.ts new file mode 100644 index 000000000..18902be2d --- /dev/null +++ b/core/modules/compare/test/unit/components/Product.spec.ts @@ -0,0 +1,30 @@ +import { mountMixinWithStore } from '@vue-storefront/unit-tests/utils'; +import { CompareProduct as Product } from '../../../components/Product' + +jest.mock('@vue-storefront/core/helpers', () => ({ + once: jest.fn() +})); +jest.mock('@vue-storefront/core/modules/compare/mixins/compareMountedMixin', () => ({})) + +describe('Product', () => { + it('removeFromCompare dispatches addItem action', () => { + const product = {}; + + const storeMock = { + modules: { + compare: { + actions: { + removeItem: jest.fn() + }, + namespaced: true + } + } + }; + + const wrapper = mountMixinWithStore(Product, storeMock); + + (wrapper.vm as any).removeFromCompare(product); + + expect(storeMock.modules.compare.actions.removeItem).toBeCalledWith(expect.anything(), product, undefined); + }) +}); diff --git a/core/modules/compare/test/unit/mixins/compareMountedMixin.spec.ts b/core/modules/compare/test/unit/mixins/compareMountedMixin.spec.ts new file mode 100644 index 000000000..df8d23f1b --- /dev/null +++ b/core/modules/compare/test/unit/mixins/compareMountedMixin.spec.ts @@ -0,0 +1,26 @@ +import { mountMixinWithStore } from '@vue-storefront/unit-tests/utils' + +import compareMountedMixin from '../../../mixins/compareMountedMixin' + +describe('compareMountedMixin', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('load compare state on mount', () => { + const storeMock = { + modules: { + compare: { + actions: { + load: jest.fn() + }, + namespaced: true + } + } + } + + mountMixinWithStore(compareMountedMixin, storeMock) + + expect(storeMock.modules.compare.actions.load).toBeCalled() + }) +}) diff --git a/core/modules/compare/test/unit/store/actions.spec.ts b/core/modules/compare/test/unit/store/actions.spec.ts new file mode 100644 index 000000000..a41b71b17 --- /dev/null +++ b/core/modules/compare/test/unit/store/actions.spec.ts @@ -0,0 +1,145 @@ +import * as types from '../../../store/mutation-types'; +import compareActions from '../../../store/actions'; +import { cacheStorage } from '@vue-storefront/core/modules/recently-viewed/index' + +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + info: jest.fn(() => jest.fn()) + } +})) +jest.mock('@vue-storefront/core/modules/recently-viewed/index', () => ({ + cacheStorage: { + getItem: jest.fn() + } +})); +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ + StorageManager: { + get: jest.fn(() => cacheStorage) + } +})) + +let product + +describe('Compare actions', () => { + beforeEach(() => { + jest.clearAllMocks(); + product = {id: 'xyz'}; + }); + + describe('load', () => { + it('should NOT load state if is already loaded', () => { + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: { isCompareLoaded: true } + }; + const wrapper = (actions: any) => actions.load(contextMock); + + wrapper(compareActions); + + expect(contextMock.commit).not.toBeCalledWith(types.SET_COMPARE_LOADED); + }); + + it('should load state if forced', () => { + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: { isCompareLoaded: true } + }; + const wrapper = (actions: any) => actions.load(contextMock, true); + + wrapper(compareActions); + + expect(contextMock.commit).toBeCalledWith(types.SET_COMPARE_LOADED); + }); + + it('should try to load state', () => { + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: { isCompareLoaded: false } + }; + const wrapper = (actions: any) => actions.load(contextMock); + + wrapper(compareActions); + + expect(contextMock.commit).toBeCalledWith(types.SET_COMPARE_LOADED); + expect(contextMock.dispatch).toBeCalledWith('fetchCurrentCompare'); + }); + + it('should NOT commit state if there are no items', async () => { + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(() => null), + getters: { isCompareLoaded: false } + }; + const wrapper = (actions: any) => actions.load(contextMock); + + await wrapper(compareActions); + + expect(contextMock.commit).not.toBeCalledWith(types.COMPARE_LOAD_COMPARE, null); + }); + + it('should commit state if are any items', async () => { + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(() => [product]), + getters: { isCompareLoaded: false } + }; + const wrapper = (actions: any) => actions.load(contextMock); + + await wrapper(compareActions); + + expect(contextMock.commit).toBeCalledWith(types.COMPARE_LOAD_COMPARE, [product]); + }); + }); + + describe('fetchCurrentCompare', () => { + it('should fetch items from cache', async () => { + const wrapper = (actions: any) => actions.fetchCurrentCompare(); + + await wrapper(compareActions); + + expect(cacheStorage.getItem).toBeCalledWith('current-compare'); + }); + }); + + describe('addItem', () => { + it('should call add product commit', async () => { + const contextMock = { + commit: jest.fn() + }; + const wrapper = (actions: any) => actions.addItem(contextMock, product); + + await wrapper(compareActions); + + expect(contextMock.commit).toBeCalledWith(types.COMPARE_ADD_ITEM, { product }); + }); + }); + + describe('removeItem', () => { + it('should call remove product commit', async () => { + const contextMock = { + commit: jest.fn() + }; + const wrapper = (actions: any) => actions.removeItem(contextMock, product); + + await wrapper(compareActions); + + expect(contextMock.commit).toBeCalledWith(types.COMPARE_DEL_ITEM, { product }); + }); + }); + + describe('clear', () => { + it('should call clear state commit', async () => { + const contextMock = { + commit: jest.fn() + }; + const wrapper = (actions: any) => actions.clear(contextMock); + + await wrapper(compareActions); + + expect(contextMock.commit).toBeCalledWith(types.COMPARE_LOAD_COMPARE, []); + }); + }); +}); diff --git a/core/modules/compare/test/unit/store/mutations.spec.ts b/core/modules/compare/test/unit/store/mutations.spec.ts new file mode 100644 index 000000000..4a9293d9c --- /dev/null +++ b/core/modules/compare/test/unit/store/mutations.spec.ts @@ -0,0 +1,164 @@ +import * as types from '../../../store/mutation-types'; +import compareMutations from '../../../store/mutations' + +describe('Compare mutations', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('COMPARE_ADD_ITEM', () => { + it('add product to compare', () => { + const stateMock = { + items: [] + } + const product = { + qty: 123, + sku: 'foo' + } + const expectedState = { + items: [ + { + qty: 123, + sku: 'foo' + } + ] + } + const wrapper = (mutations: any) => mutations[types.COMPARE_ADD_ITEM](stateMock, { product }) + + wrapper(compareMutations) + + expect(stateMock).toEqual(expectedState) + }) + + it('don\'t add product if there is one with the same sku', () => { + const stateMock = { + items: [ + { + qty: 1233, + sku: 'foo' + } + ] + } + const product = { + qty: 123, + sku: 'foo' + } + const expectedState = { + items: [ + { + qty: 1233, + sku: 'foo' + } + ] + } + const wrapper = (mutations: any) => mutations[types.COMPARE_ADD_ITEM](stateMock, { product }) + + wrapper(compareMutations) + + expect(stateMock).toEqual(expectedState) + }) + }); + + describe('COMPARE_DEL_ITEM', () => { + it('remove product if there is one with the same sku', () => { + const stateMock = { + items: [ + { + qty: 1233, + sku: 'foo' + } + ] + } + const product = { + qty: 123, + sku: 'foo' + } + const expectedState = { + items: [] + } + const wrapper = (mutations: any) => mutations[types.COMPARE_DEL_ITEM](stateMock, { product }) + + wrapper(compareMutations) + + expect(stateMock).toEqual(expectedState) + }) + + it('don\'t remove product if there is not any with same sku', () => { + const stateMock = { + items: [ + { + qty: 1233, + sku: 'boo' + } + ] + } + const product = { + qty: 123, + sku: 'foo' + } + const expectedState = { + items: [ + { + qty: 1233, + sku: 'boo' + } + ] + } + const wrapper = (mutations: any) => mutations[types.COMPARE_DEL_ITEM](stateMock, { product }) + + wrapper(compareMutations) + + expect(stateMock).toEqual(expectedState) + }) + }); + + describe('COMPARE_LOAD_COMPARE', () => { + it('should load state with products to compare', () => { + const stateMock = { + items: [] + } + const product = { + qty: 123, + sku: 'foo' + } + const expectedState = { + items: [product, product] + } + const wrapper = (mutations: any) => mutations[types.COMPARE_LOAD_COMPARE](stateMock, [product, product]) + + wrapper(compareMutations) + + expect(stateMock).toEqual(expectedState) + }) + }); + + describe('SET_COMPARE_LOADED', () => { + it('should set state as loaded', () => { + const stateMock = { + loaded: false + } + const expectedState = { + loaded: true + } + const wrapper = (mutations: any) => mutations[types.SET_COMPARE_LOADED](stateMock) + + wrapper(compareMutations) + + expect(stateMock).toEqual(expectedState) + }) + + it('should set state as not loaded', () => { + const stateMock = { + loaded: true + } + const expectedState = { + loaded: false + } + const wrapper = (mutations: any) => mutations[types.SET_COMPARE_LOADED](stateMock, false) + + wrapper(compareMutations) + + expect(stateMock).toEqual(expectedState) + }) + }); +}); diff --git a/core/modules/mailer/index.ts b/core/modules/mailer/index.ts index cb8ed5766..b052161b2 100644 --- a/core/modules/mailer/index.ts +++ b/core/modules/mailer/index.ts @@ -1,8 +1,6 @@ -import { module } from './store' -import { createModule } from '@vue-storefront/core/lib/module' +import { StorefrontModule } from '@vue-storefront/core/lib/modules' +import { mailerStore } from './store' -export const KEY = 'mailer' -export const Mailer = createModule({ - key: KEY, - store: { modules: [{ key: KEY, module }] } -}) +export const MailerModule: StorefrontModule = function ({store}) { + store.registerModule('mailer', mailerStore) +} diff --git a/core/modules/mailer/store/index.ts b/core/modules/mailer/store/index.ts index 8dca52edf..aad78a607 100644 --- a/core/modules/mailer/store/index.ts +++ b/core/modules/mailer/store/index.ts @@ -1,37 +1,45 @@ +import { Logger } from '@vue-storefront/core/lib/logger' import MailItem from '../types/MailItem' import { Module } from 'vuex' import config from 'config' import { processURLAddress } from '@vue-storefront/core/helpers' -export const module: Module = { +export const mailerStore: Module = { namespaced: true, actions: { - sendEmail (context, letter: MailItem) { - return new Promise((resolve, reject) => { - fetch(processURLAddress(config.mailer.endpoint.token)) - .then(res => res.json()) - .then(res => { - if (res.code === 200) { - fetch(processURLAddress(config.mailer.endpoint.send()), { + async sendEmail (context, letter: MailItem) { + try { + const res = await fetch(processURLAddress(config.mailer.endpoint.token)) + const resData = await res.json() + if (resData.code === 200) { + try { + const res = await fetch( + processURLAddress(config.mailer.endpoint.send), + { method: 'POST', mode: 'cors', headers: { - 'Accept': 'application/json', + Accept: 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ ...letter, - token: res.result + token: resData.result }) - }) - .then(res => resolve(res)) - .catch(() => reject()) - } else { - reject() - } - }) - .catch(() => reject()) - }) + } + ) + return res + } catch (e) { + Logger.error(e, 'mailer')() + throw new Error(e) + } + } else { + throw new Error(resData.code) + } + } catch (e) { + Logger.error(e, 'mailer')() + throw new Error(e) + } } } } diff --git a/core/modules/mailer/test/unit/sendEmail.spec.ts b/core/modules/mailer/test/unit/sendEmail.spec.ts new file mode 100644 index 000000000..a36001b3e --- /dev/null +++ b/core/modules/mailer/test/unit/sendEmail.spec.ts @@ -0,0 +1,47 @@ +import { mailerStore } from '../../store/index' +import config from 'config' + +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('@vue-storefront/core/lib/storage-manager', () => jest.fn()) +jest.mock('@vue-storefront/core/app', () => jest.fn()) +jest.mock('@vue-storefront/core/lib/multistore', () => jest.fn()) +jest.mock('@vue-storefront/core/store', () => ({ Module: jest.fn() })) +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + error: jest.fn(() => jest.fn()) + } +})) + +describe('Mailer store module', () => { + const letterMock = {} + const contextMock = {}; + const wrapper = (actions: any) => actions.sendEmail(contextMock, letterMock) + + beforeEach(() => { + jest.clearAllMocks(); + fetchMock.resetMocks() + }) + + it('should send email succesfully', async () => { + fetchMock.mockResponses( + [ JSON.stringify({ code: 200 }), { status: 200 } ], + [ JSON.stringify({ send: true }), { status: 200 } ] + ) + + const res = await wrapper(mailerStore.actions); + const resData = await res.json() + + expect(resData.send).toBe(true) + }) + + it('should thrown error when response code is wrong', async () => { + const wrongResponseCode = 201; + fetchMock.mockResponseOnce(JSON.stringify({ code: wrongResponseCode })) + + try { + const res = await wrapper(mailerStore.actions) + } catch (e) { + expect(e.message).toBe(`Error: ${wrongResponseCode}`) + } + }) +}) diff --git a/core/modules/newsletter/index.ts b/core/modules/newsletter/index.ts index 1be1993e9..991b482b6 100644 --- a/core/modules/newsletter/index.ts +++ b/core/modules/newsletter/index.ts @@ -1,11 +1,8 @@ -import { module } from './store' -import { VueStorefrontModule, VueStorefrontModuleConfig } from '@vue-storefront/core/lib/module' -import { initCacheStorage } from '@vue-storefront/core/helpers/initCacheStorage' +import { newsletterStore } from './store' +import { StorefrontModule } from '@vue-storefront/core/lib/modules'; +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' -export const KEY = 'newsletter' -export const cacheStorage = initCacheStorage(KEY) -const moduleConfig: VueStorefrontModuleConfig = { - key: KEY, - store: { modules: [{ key: KEY, module }] } +export const NewsletterModule: StorefrontModule = function ({store}) { + StorageManager.init('newsletter') + store.registerModule('newsletter', newsletterStore) } -export const Newsletter = new VueStorefrontModule(moduleConfig) diff --git a/core/modules/newsletter/mixins/SubscriptionStatus.ts b/core/modules/newsletter/mixins/SubscriptionStatus.ts index 3d51f860f..c906ab7ef 100644 --- a/core/modules/newsletter/mixins/SubscriptionStatus.ts +++ b/core/modules/newsletter/mixins/SubscriptionStatus.ts @@ -30,22 +30,9 @@ export default { } }, methods: { - onLoggedIn () { + async onLoggedIn () { this.email = this.$store.state.user.current.email - this.checkStatus(response => { - this.user.isSubscribed = response.result === 'subscribed' - }) - }, - checkStatus (success?: Function, failure?: Function) { - // argument omitted for validation purposes - if (!this.$v.$invalid) { - return this.$store.dispatch('newsletter/status', this.email).then(res => { - if (success) success(res) - }).catch(err => { - if (failure) failure(err) - } - ) - } + this.user.isSubscribed = await this.$store.dispatch('newsletter/status', this.email) } }, beforeMount () { diff --git a/core/modules/newsletter/mixins/Unsubscribe.ts b/core/modules/newsletter/mixins/Unsubscribe.ts index 8816fdc70..169e9be7b 100644 --- a/core/modules/newsletter/mixins/Unsubscribe.ts +++ b/core/modules/newsletter/mixins/Unsubscribe.ts @@ -24,14 +24,16 @@ export default { } }, methods: { - unsubscribe () { + unsubscribe (success?: Function, failure?: Function) { // argument omitted for validation purposes if (!this.$v.$invalid) { return this.$store.dispatch('newsletter/unsubscribe', this.email).then(res => { + if (success) success(res) this.$emit('unsubscribed', res) - }).catch(err => + }).catch(err => { + if (failure) failure(err) this.$emit('unsubscription-error', err) - ) + }) } } } diff --git a/core/modules/newsletter/store/index.ts b/core/modules/newsletter/store/index.ts index d07cbaa35..e91edceff 100644 --- a/core/modules/newsletter/store/index.ts +++ b/core/modules/newsletter/store/index.ts @@ -1,11 +1,10 @@ import * as types from './mutation-types' import { Module } from 'vuex' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' import { NewsletterState } from '../types/NewsletterState' -import { cacheStorage } from '../' -import config from 'config' -import { processURLAddress } from '@vue-storefront/core/helpers' +import { NewsletterService } from '@vue-storefront/core/data-resolver' -export const module: Module = { +export const newsletterStore: Module = { namespaced: true, state: { isSubscribed: null, @@ -27,61 +26,40 @@ export const module: Module = { } }, actions: { - status ({ commit, state }, email): Promise { - return new Promise((resolve, reject) => { - fetch(processURLAddress(config.newsletter.endpoint) + '?email=' + encodeURIComponent(email), { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - mode: 'cors' - }).then(res => res.json()) - .then(res => { - if (res.result === 'subscribed') { - commit(types.SET_EMAIL, email) - commit(types.NEWSLETTER_SUBSCRIBE) - } else { - commit(types.NEWSLETTER_UNSUBSCRIBE) - } - resolve(res) - }).catch(err => { - reject(err) - }) - }) - }, - subscribe ({ commit, state }, email): Promise { - if (!state.isSubscribed) { - return new Promise((resolve, reject) => { - fetch(processURLAddress(config.newsletter.endpoint), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - mode: 'cors', - body: JSON.stringify({ email }) - }).then(res => { - commit(types.NEWSLETTER_SUBSCRIBE) - commit(types.SET_EMAIL, email) - cacheStorage.setItem('email', email) - resolve(res) - }).catch(err => { - reject(err) - }) - }) + async status ({ commit }, email): Promise { + const isSubscribed = await NewsletterService.isSubscribed(email) + + if (isSubscribed) { + commit(types.SET_EMAIL, email) + commit(types.NEWSLETTER_SUBSCRIBE) + } else { + commit(types.NEWSLETTER_UNSUBSCRIBE) } + + return isSubscribed }, - unsubscribe ({ commit, state }, email): Promise { - if (state.isSubscribed) { - return new Promise((resolve, reject) => { - fetch(processURLAddress(config.newsletter.endpoint), { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - mode: 'cors', - body: JSON.stringify({ email }) - }).then(res => { - commit(types.NEWSLETTER_UNSUBSCRIBE) - resolve(res) - }).catch(err => { - reject(err) - }) - }) - } + async subscribe ({ commit, getters, dispatch }, email): Promise { + if (getters.isSubscribed) return + + const subscribeResponse = await NewsletterService.subscribe(email) + + commit(types.NEWSLETTER_SUBSCRIBE) + commit(types.SET_EMAIL, email) + await dispatch('storeToCache', { email }) + + return subscribeResponse + }, + async unsubscribe ({ commit, getters }, email): Promise { + if (!getters.isSubscribed) return + + const unsubscribeResponse = await NewsletterService.unsubscribe(email) + commit(types.NEWSLETTER_UNSUBSCRIBE) + + return unsubscribeResponse + }, + async storeToCache (context, { email }) { + const newsletterStorage = StorageManager.get('newsletter') + await newsletterStorage.setItem('email', email) } } } diff --git a/core/modules/newsletter/test/unit/mixins/SubscriptionStatus.spec.ts b/core/modules/newsletter/test/unit/mixins/SubscriptionStatus.spec.ts index 1f87d5db9..92b9bbc39 100644 --- a/core/modules/newsletter/test/unit/mixins/SubscriptionStatus.spec.ts +++ b/core/modules/newsletter/test/unit/mixins/SubscriptionStatus.spec.ts @@ -205,7 +205,7 @@ describe('SubscriptionStatus', () => { expect(wrapper).toMatchInlineSnapshot('

should be displayed

') }) - it('method checkStatus can be called without callbacks', () => { + it('method onLoggedIn can be called without callbacks', () => { const storeMock = { modules: { newsletter: { @@ -218,7 +218,12 @@ describe('SubscriptionStatus', () => { namespaced: true }, user: { - namespaced: true + namespaced: true, + state: { + current: { + email: 'john@doe.com' + } + } } } } @@ -235,12 +240,12 @@ describe('SubscriptionStatus', () => { } }); - (wrapper.vm as any).checkStatus() + (wrapper.vm as any).onLoggedIn() expect(storeMock.modules.newsletter.actions.status).toBeCalled() }) - it('method checkStatus can be called without callbacks', () => { + it('method onLoggedIn can be called without callbacks', () => { const storeMock = { modules: { newsletter: { @@ -253,42 +258,12 @@ describe('SubscriptionStatus', () => { namespaced: true }, user: { - namespaced: true - } - } - } - - const wrapper = mountMixinWithStore(SubscriptionStatus, storeMock, { - mocks: { - $emit: jest.fn(), - $v: { - $invalid: false - }, - $bus: { - $on: jest.fn() - } - } - }); - - (wrapper.vm as any).checkStatus() - - expect(storeMock.modules.newsletter.actions.status).toBeCalled() - }) - - it('method checkStatus handles dispatching fetching status data action that fails', async () => { - const storeMock = { - modules: { - newsletter: { - actions: { - status: jest.fn(() => Promise.reject('fetching failed')) - }, - getters: { - isSubscribed: jest.fn(() => true) - }, - namespaced: true - }, - user: { - namespaced: true + namespaced: true, + state: { + current: { + email: 'john@doe.com' + } + } } } } @@ -305,45 +280,8 @@ describe('SubscriptionStatus', () => { } }); - await (wrapper.vm as any).checkStatus() + (wrapper.vm as any).onLoggedIn() expect(storeMock.modules.newsletter.actions.status).toBeCalled() }) - - it('method checkStatus handles dispatching fetching status data action that fails with custom handler', async () => { - const storeMock = { - modules: { - newsletter: { - actions: { - status: jest.fn(() => Promise.reject('fetching failed')) - }, - getters: { - isSubscribed: jest.fn(() => true) - }, - namespaced: true - }, - user: { - namespaced: true - } - } - } - - const wrapper = mountMixinWithStore(SubscriptionStatus, storeMock, { - mocks: { - $emit: jest.fn(), - $v: { - $invalid: false - }, - $bus: { - $on: jest.fn() - } - } - }); - - const errorHandler = jest.fn() - - await (wrapper.vm as any).checkStatus(() => {}, errorHandler) - - expect(errorHandler).toBeCalled() - }) }) diff --git a/core/modules/newsletter/test/unit/store/index.spec.ts b/core/modules/newsletter/test/unit/store/index.spec.ts new file mode 100644 index 000000000..5eaec3960 --- /dev/null +++ b/core/modules/newsletter/test/unit/store/index.spec.ts @@ -0,0 +1,201 @@ +import * as types from '@vue-storefront/core/modules/newsletter/store/mutation-types'; +import { StorageManager } from '@vue-storefront/core/lib/storage-manager'; +import { NewsletterService } from '@vue-storefront/core/data-resolver'; +import { newsletterStore } from '@vue-storefront/core/modules/newsletter/store'; + +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ + StorageManager: { + get: jest.fn() + } +})); + +jest.mock('@vue-storefront/core/data-resolver', () => ({ + NewsletterService: { + isSubscribed: jest.fn(), + subscribe: jest.fn(), + unsubscribe: jest.fn() + } +})); + +describe('Newsletter actions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('status', () => { + it('should set e-mail and update newsletter status if it is subscribed', async () => { + const isSubscribed = true; + const email = 'example@domain.com'; + const mockContext = { + commit: jest.fn() + }; + + (NewsletterService.isSubscribed as any).mockImplementation(() => (new Promise(resolve => resolve(isSubscribed)))); + + const status = await (newsletterStore.actions as any).status(mockContext, email); + + expect(NewsletterService.isSubscribed).toHaveBeenCalledWith(email); + expect(mockContext.commit).toHaveBeenCalledTimes(2); + expect(mockContext.commit).toHaveBeenNthCalledWith(1, types.SET_EMAIL, email); + expect(mockContext.commit).toHaveBeenNthCalledWith(2, types.NEWSLETTER_SUBSCRIBE); + expect(status).toBe(isSubscribed); + }); + + it('should not set e-mail but only update newsletter status if it is not subscribed', async () => { + const isSubscribed = false; + const email = 'example@domain.com'; + const mockContext = { + commit: jest.fn() + }; + + (NewsletterService.isSubscribed as any).mockImplementation(() => (new Promise(resolve => resolve(isSubscribed)))); + + const status = await (newsletterStore.actions as any).status(mockContext, email); + + expect(NewsletterService.isSubscribed).toHaveBeenCalledWith(email); + expect(mockContext.commit).toHaveBeenCalledTimes(1); + expect(mockContext.commit).toHaveBeenNthCalledWith(1, types.NEWSLETTER_UNSUBSCRIBE); + expect(status).toBe(isSubscribed); + }); + }); + + describe('subscribe', () => { + it('should not subscribe if it is already subscribed', async () => { + const mockContext = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: { + isSubscribed: true + } + }; + + await (newsletterStore.actions as any).subscribe(mockContext); + + expect(NewsletterService.subscribe).not.toHaveBeenCalled(); + expect(mockContext.commit).not.toHaveBeenCalled(); + expect(mockContext.dispatch).not.toHaveBeenCalled(); + }); + + it('should subscribe if it is not subscribed', async () => { + const email = 'example@domain.com'; + const mockContext = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: { + isSubscribed: false + } + }; + + (NewsletterService.subscribe as any).mockImplementation(() => (new Promise(resolve => resolve(true)))); + + const status = await (newsletterStore.actions as any).subscribe(mockContext, email); + + expect(NewsletterService.subscribe).toHaveBeenCalledWith(email); + expect(mockContext.commit).toHaveBeenCalledTimes(2); + expect(mockContext.commit).toHaveBeenNthCalledWith(1, types.NEWSLETTER_SUBSCRIBE); + expect(mockContext.commit).toHaveBeenNthCalledWith(2, types.SET_EMAIL, email); + expect(mockContext.dispatch).toHaveBeenCalledWith('storeToCache', { email }); + expect(status).toBe(true); + }); + }); + + describe('unsubscribe', () => { + it('should not unsubscribe if it is already unsubscribed', async () => { + const mockContext = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: { + isSubscribed: false + } + }; + + await (newsletterStore.actions as any).unsubscribe(mockContext); + + expect(NewsletterService.unsubscribe).not.toHaveBeenCalled(); + expect(mockContext.commit).not.toHaveBeenCalled(); + expect(mockContext.dispatch).not.toHaveBeenCalled(); + }); + + it('should unsubscribe if it is subscribed', async () => { + const email = 'example@domain.com'; + const mockContext = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: { + isSubscribed: true + } + }; + + (NewsletterService.unsubscribe as any).mockImplementation(() => (new Promise(resolve => resolve(true)))); + + const status = await (newsletterStore.actions as any).unsubscribe(mockContext, email); + + expect(NewsletterService.unsubscribe).toHaveBeenCalledWith(email); + expect(mockContext.commit).toHaveBeenCalledWith(types.NEWSLETTER_UNSUBSCRIBE); + expect(status).toBe(true); + }); + }); + + describe('storeToCache', () => { + it('should store email in cache', () => { + const email = 'example@domain.com'; + const mockSetItem = jest.fn(() => (new Promise(resolve => resolve(true)))); + + (StorageManager.get as jest.Mock).mockImplementation(() => ({ + setItem: mockSetItem + })); + + (newsletterStore.actions as any).storeToCache(null, { email }); + + expect(StorageManager.get).toHaveBeenCalledWith('newsletter'); + expect(mockSetItem).toHaveBeenCalledWith('email', email); + }); + }); +}); + +describe('Newsletter mutations', () => { + it('NEWSLETTER_SUBSCRIBE should set subscription state', () => { + const mockState = { isSubscribed: false }; + const expectedState = { isSubscribed: true }; + + (newsletterStore.mutations as any)[types.NEWSLETTER_SUBSCRIBE](mockState); + + expect(mockState).toEqual(expectedState); + }); + + it('NEWSLETTER_UNSUBSCRIBE should set unsubscription state', () => { + const mockState = { isSubscribed: true }; + const expectedState = { isSubscribed: false }; + + (newsletterStore.mutations as any)[types.NEWSLETTER_UNSUBSCRIBE](mockState); + + expect(mockState).toEqual(expectedState); + }); + + it('SET_EMAIL should set email address', () => { + const email = 'example@domain.com'; + const mockState = { email: '' }; + const expectedState = { email }; + + (newsletterStore.mutations as any)[types.SET_EMAIL](mockState, email); + + expect(mockState).toEqual(expectedState); + }); +}); + +describe('Newsletter getters', () => { + it('should return subscription status', () => { + const isSubscribed = (newsletterStore.getters as any).isSubscribed({ isSubscribed: true }); + const isNotSubscribed = (newsletterStore.getters as any).isSubscribed({ isSubscribed: false }); + + expect(isSubscribed).toBe(true); + expect(isNotSubscribed).toBe(false); + }); + + it('should return email address', () => { + const email = 'example@domain.com'; + const expectedEmail = (newsletterStore.getters as any).email({ email }); + + expect(expectedEmail).toBe(email); + }); +}); diff --git a/core/modules/notification/index.ts b/core/modules/notification/index.ts index 3a63f1d9c..a619b90ab 100644 --- a/core/modules/notification/index.ts +++ b/core/modules/notification/index.ts @@ -1,8 +1,6 @@ -import { module } from './store' -import { createModule } from '@vue-storefront/core/lib/module' +import { notificationStore } from './store' +import { StorefrontModule } from '@vue-storefront/core/lib/modules'; -export const KEY = 'notification' -export const Notification = createModule({ - key: KEY, - store: { modules: [{ key: KEY, module }] } -}) +export const NotificationModule: StorefrontModule = function ({store}) { + store.registerModule('notification', notificationStore) +} diff --git a/core/modules/notification/store/index.ts b/core/modules/notification/store/index.ts index 9f4c9b2a6..05bd8ab72 100644 --- a/core/modules/notification/store/index.ts +++ b/core/modules/notification/store/index.ts @@ -2,7 +2,7 @@ import { Module } from 'vuex' import NotificationItem from '../types/NotificationItem' import NotificationState from '../types/NotificationState' -export const module: Module = { +export const notificationStore: Module = { namespaced: true, state: { notifications: [] @@ -19,18 +19,25 @@ export const module: Module = { } }, actions: { - spawnNotification ({ commit, state, dispatch }, notification: NotificationItem) { + spawnNotification ({ commit, state, dispatch }, notification: NotificationItem): NotificationItem { if (state.notifications.length > 0 && state.notifications[state.notifications.length - 1].message === notification.message ) { return } - commit('add', notification) - if (!notification.hasNoTimeout) { + + const id = Math.floor(Math.random() * 100000) + const newNotification = { id, ...notification } + + commit('add', newNotification) + + if (!newNotification.hasNoTimeout) { setTimeout(() => { - dispatch('removeNotification') - }, notification.timeToLive || 5000) + dispatch('removeNotificationById', id) + }, newNotification.timeToLive || 5000) } + + return newNotification }, removeNotification ({ commit, state }, index?: number) { if (!index) { @@ -38,6 +45,13 @@ export const module: Module = { } else { commit('remove', index) } + }, + removeNotificationById ({ commit, state }, id: number) { + const index = state.notifications.findIndex(notification => notification.id === id) + + if (index !== -1) { + commit('remove', index) + } } } } diff --git a/core/modules/notification/test/unit/actions.spec.ts b/core/modules/notification/test/unit/actions.spec.ts new file mode 100644 index 000000000..f4e3499a4 --- /dev/null +++ b/core/modules/notification/test/unit/actions.spec.ts @@ -0,0 +1,180 @@ +import { notificationStore } from '../../store'; +import NotificationItem from '../../types/NotificationItem'; + +jest.useFakeTimers() + +describe('Notification actions', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers() + }); + + describe('spawnNotification', () => { + it('should add new notification', async () => { + const contextMock = { + commit: jest.fn(), + state: { + notifications: [] + } + }; + const notification: NotificationItem = { + type: 'success', + message: 'Success text.', + action1: { label: 'OK' } + } + const wrapper = (actions: any) => actions.spawnNotification(contextMock, notification); + + const newNotification = await wrapper(notificationStore.actions); + + expect(contextMock.commit).toBeCalledWith('add', newNotification); + }); + + it('should NOT add new notification if last one has the same message', async () => { + const notification: NotificationItem = { + type: 'success', + message: 'Success text.', + action1: { label: 'OK' } + } + const contextMock = { + commit: jest.fn(), + state: { + notifications: [notification] + } + }; + const wrapper = (actions: any) => actions.spawnNotification(contextMock, notification); + + await wrapper(notificationStore.actions); + + expect(contextMock.commit).not.toBeCalledWith('add', notification); + }); + + it('should remove new notification after timeToLive (3000ms)', async () => { + const dispatch = jest.fn() + const contextMock = { + dispatch, + commit: jest.fn(), + state: { + notifications: [] + } + }; + const notification: NotificationItem = { + type: 'success', + message: 'Success text.', + action1: { label: 'OK' }, + timeToLive: 3000 + } + const wrapper = (actions: any) => actions.spawnNotification(contextMock, notification); + + const newNotification = await wrapper(notificationStore.actions); + + expect(contextMock.dispatch).not.toHaveBeenLastCalledWith('removeNotificationById'); + + jest.advanceTimersByTime(3000); + + expect(contextMock.dispatch).toHaveBeenLastCalledWith('removeNotificationById', newNotification.id); + }); + + it('should NOT remove new notification if hasNoTimeout is set on true', async () => { + const dispatch = jest.fn() + const contextMock = { + dispatch, + commit: jest.fn(), + state: { + notifications: [] + } + }; + const notification: NotificationItem = { + type: 'success', + message: 'Success text.', + action1: { label: 'OK' }, + hasNoTimeout: true + } + const wrapper = (actions: any) => actions.spawnNotification(contextMock, notification); + + await wrapper(notificationStore.actions); + + jest.advanceTimersByTime(5000); + + expect(contextMock.dispatch).not.toHaveBeenLastCalledWith('removeNotificationById'); + }); + }); + + describe('removeNotification', () => { + it('should call \'remove\' commit with specific index', async () => { + const contextMock = { + commit: jest.fn(), + state: { + notifications: [] + } + }; + const wrapper = (actions: any) => actions.removeNotification(contextMock, 1); + + await wrapper(notificationStore.actions); + + expect(contextMock.commit).toBeCalledWith('remove', 1); + }); + + it('if there is no index provided then should call \'remove\' commit with last index', async () => { + const notification: NotificationItem = { + type: 'success', + message: 'Success text.', + action1: { label: 'OK' } + } + const contextMock = { + commit: jest.fn(), + state: { + notifications: [notification, notification] + } + }; + const wrapper = (actions: any) => actions.removeNotification(contextMock); + + await wrapper(notificationStore.actions); + + expect(contextMock.commit).toBeCalledWith('remove', 1); + }); + }) + + describe('removeNotificationById', () => { + it('should call \'remove\' commit if id is found', async () => { + const contextMock = { + commit: jest.fn(), + state: { + notifications: [ + { + id: 1234, + type: 'success', + message: 'Success text.', + action1: { label: 'OK' } + } + ] + } + }; + const wrapper = (actions: any) => actions.removeNotificationById(contextMock, 1234); + + await wrapper(notificationStore.actions); + + expect(contextMock.commit).toBeCalledWith('remove', 0); + }); + + it('should not call \'remove\' commit if id is not found', async () => { + const contextMock = { + commit: jest.fn(), + state: { + notifications: [ + { + id: 1230, + type: 'success', + message: 'Success text.', + action1: { label: 'OK' } + } + ] + } + }; + const wrapper = (actions: any) => actions.removeNotificationById(contextMock, 1234); + + await wrapper(notificationStore.actions); + + expect(contextMock.commit).not.toBeCalledWith('remove', 0); + }); + }) +}) diff --git a/core/modules/notification/test/unit/mutations.spec.ts b/core/modules/notification/test/unit/mutations.spec.ts new file mode 100644 index 000000000..5f4a2e77a --- /dev/null +++ b/core/modules/notification/test/unit/mutations.spec.ts @@ -0,0 +1,55 @@ +import { notificationStore } from '../../store'; +import NotificationItem from '../../types/NotificationItem'; + +describe('Notification actions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('add', () => { + it('should add new notification to state', async () => { + const stateMock = { + notifications: [] + } + const notification: NotificationItem = { + type: 'success', + message: 'Success text.', + action1: { label: 'OK' } + } + const expectedState = { + notifications: [notification] + } + const wrapper = (mutations: any) => mutations['add'](stateMock, notification) + + wrapper(notificationStore.mutations) + + expect(stateMock).toEqual(expectedState) + }); + }) + + describe('remove', () => { + it('should remove notification from state with provided index', async () => { + const notification: NotificationItem = { + type: 'success', + message: 'Success text.', + action1: { label: 'OK' } + } + const notification2: NotificationItem = { + type: 'success', + message: 'Success text2.', + action1: { label: 'OK' } + } + const stateMock = { + notifications: [notification, notification2] + } + const expectedState = { + notifications: [notification2] + } + const wrapper = (mutations: any) => mutations['remove'](stateMock, 0) + + wrapper(notificationStore.mutations) + + expect(stateMock).toEqual(expectedState) + }); + }) +}) diff --git a/core/modules/notification/types/NotificationItem.ts b/core/modules/notification/types/NotificationItem.ts index 8bb6b8fa5..ed55e44fa 100644 --- a/core/modules/notification/types/NotificationItem.ts +++ b/core/modules/notification/types/NotificationItem.ts @@ -4,6 +4,7 @@ interface ActionItem { } export default interface NotificationItem { + id?: number, type: string, message: string, timeToLive?: number, diff --git a/core/modules/offline-order/components/CancelOrders.ts b/core/modules/offline-order/components/CancelOrders.ts index 062f3fc58..220372f62 100644 --- a/core/modules/offline-order/components/CancelOrders.ts +++ b/core/modules/offline-order/components/CancelOrders.ts @@ -1,19 +1,10 @@ -import * as localForage from 'localforage' -import store from '@vue-storefront/core/store' - -import UniversalStorage from '@vue-storefront/core/store/lib/storage' import { Logger } from '@vue-storefront/core/lib/logger' -import config from 'config' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' export const CancelOrders = { methods: { cancelOrders () { - const ordersCollection = new UniversalStorage(localForage.createInstance({ - name: 'shop', - storeName: 'orders', - driver: localForage[config.localForage.defaultDrivers['orders']] - })) - + const ordersCollection = StorageManager.get('orders') ordersCollection.iterate((order, id, iterationNumber) => { if (!order.transmited) { ordersCollection.removeItem(id) diff --git a/core/modules/offline-order/helpers/onNetworkStatusChange.ts b/core/modules/offline-order/helpers/onNetworkStatusChange.ts index 3c4347965..ecd806c19 100644 --- a/core/modules/offline-order/helpers/onNetworkStatusChange.ts +++ b/core/modules/offline-order/helpers/onNetworkStatusChange.ts @@ -1,9 +1,7 @@ -import * as localForage from 'localforage' import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus/index' -import UniversalStorage from '@vue-storefront/core/store/lib/storage' -import { currentStoreView } from '@vue-storefront/core/lib/multistore' import { Logger } from '@vue-storefront/core/lib/logger' import config from 'config' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' export function onNetworkStatusChange (store) { Logger.log('Are we online: ' + navigator.onLine, 'offline-order')() @@ -15,12 +13,7 @@ export function onNetworkStatusChange (store) { EventBus.$emit('order/PROCESS_QUEUE', { config: config }) // process checkout queue } else { const ordersToConfirm = [] - const storeView = currentStoreView() - const ordersCollection = new UniversalStorage(localForage.createInstance({ - name: 'shop', - storeName: 'orders', - driver: localForage[config.localForage.defaultDrivers['orders']] - })) + const ordersCollection = StorageManager.get('orders') ordersCollection.iterate((order, id, iterationNumber) => { if (!order.transmited) { diff --git a/src/modules/order-history/components/UserOrders.ts b/core/modules/order/components/UserOrdersHistory.ts similarity index 92% rename from src/modules/order-history/components/UserOrders.ts rename to core/modules/order/components/UserOrdersHistory.ts index 0e7fb1d9a..741257d98 100644 --- a/src/modules/order-history/components/UserOrders.ts +++ b/core/modules/order/components/UserOrdersHistory.ts @@ -1,9 +1,8 @@ import MyOrders from '@vue-storefront/core/compatibility/components/blocks/MyAccount/MyOrders' -import { mapGetters } from 'vuex'; import onBottomScroll from '@vue-storefront/core/mixins/onBottomScroll' export default { - name: 'UserOrders', + name: 'UserOrdersHistory', mixins: [MyOrders, onBottomScroll], data () { return { diff --git a/core/modules/order/components/UserSingleOrder.ts b/core/modules/order/components/UserSingleOrder.ts index faf35bd6e..273c36cec 100644 --- a/core/modules/order/components/UserSingleOrder.ts +++ b/core/modules/order/components/UserSingleOrder.ts @@ -1,25 +1,34 @@ +import { mapGetters } from 'vuex'; + /** * Component responsible for displaying single user order. Requires User module. */ export const UserSingleOrder = { name: 'UserSingleOrder', computed: { - ordersHistory () { - return this.$store.state.user.orders_history.items - }, + ...mapGetters({ + ordersHistory: 'user/getOrdersHistory' + }), order () { - return this.ordersHistory.find(order => { - return parseInt(order.entity_id) === parseInt(this.$route.params.orderId) - }, (this)) + return this.ordersHistory.find(order => + parseInt(order.entity_id) === parseInt(this.$route.params.orderId) + ) }, paymentMethod () { - return this.order.payment.additional_information[0] + return this.order && this.order.payment.additional_information[0] }, billingAddress () { - return this.order.billing_address + return this.order && this.order.billing_address }, shippingAddress () { - return this.order.extension_attributes.shipping_assignments[0].shipping.address + return this.order && this.order.extension_attributes.shipping_assignments[0].shipping.address + }, + singleOrderItems () { + if (!this.order) return [] + + return this.order.items.filter((item) => { + return !item.parent_item_id + }) } }, methods: { diff --git a/core/modules/order/helpers/index.ts b/core/modules/order/helpers/index.ts new file mode 100644 index 000000000..22095bfec --- /dev/null +++ b/core/modules/order/helpers/index.ts @@ -0,0 +1,5 @@ +import optimizeOrder from './optimizeOrder' +import prepareOrder from './prepareOrder' +import notifications from './notifications' + +export { optimizeOrder, prepareOrder, notifications } diff --git a/core/modules/order/helpers/notifications.ts b/core/modules/order/helpers/notifications.ts new file mode 100644 index 000000000..2c419bcce --- /dev/null +++ b/core/modules/order/helpers/notifications.ts @@ -0,0 +1,18 @@ +import i18n from '@vue-storefront/i18n' +import config from 'config' + +const internalValidationError = () => ({ + type: 'error', + message: i18n.t('Internal validation error. Please check if all required fields are filled in. Please contact us on {email}', { email: config.mailer.contactAddress }), + action1: { label: i18n.t('OK') } +}) + +const orderCannotTransfered = () => ({ + type: 'error', + message: i18n.t('The order can not be transfered because of server error. Order has been queued'), + action1: { label: i18n.t('OK') } +}) + +const notifications = { internalValidationError, orderCannotTransfered } + +export default notifications diff --git a/core/modules/order/helpers/optimizeOrder.ts b/core/modules/order/helpers/optimizeOrder.ts new file mode 100644 index 000000000..d2b349bf9 --- /dev/null +++ b/core/modules/order/helpers/optimizeOrder.ts @@ -0,0 +1,16 @@ +import config from 'config' +import omit from 'lodash-es/omit' +import { Order } from '@vue-storefront/core/modules/order/types/Order' + +const optimizeOrder = (order: Order): Order => { + if (config.entities.optimize && config.entities.optimizeShoppingCart) { + return { + ...order, + products: order.products.map(product => omit(product, ['configurable_options', 'configurable_children'])) as Order['products'] + } + } + + return order +} + +export default optimizeOrder diff --git a/core/modules/order/helpers/prepareOrder.ts b/core/modules/order/helpers/prepareOrder.ts new file mode 100644 index 000000000..424cc00dc --- /dev/null +++ b/core/modules/order/helpers/prepareOrder.ts @@ -0,0 +1,14 @@ +import { Order } from '@vue-storefront/core/modules/order/types/Order' +import { currentStoreView } from '@vue-storefront/core/lib/multistore' + +const prepareOrder = (order: Order): Order => { + const storeView = currentStoreView() + const storeCode = storeView.storeCode ? storeView.storeCode : order.store_code + + return { + ...order, + store_code: storeCode + } +} + +export default prepareOrder diff --git a/core/modules/order/hooks.ts b/core/modules/order/hooks.ts new file mode 100644 index 000000000..e15745795 --- /dev/null +++ b/core/modules/order/hooks.ts @@ -0,0 +1,33 @@ +import { createListenerHook, createMutatorHook } from '@vue-storefront/core/lib/hooks' + +const { + hook: beforePlaceOrderHook, + executor: beforePlaceOrderExecutor +} = createMutatorHook() + +const { + hook: afterPlaceOrderHook, + executor: afterPlaceOrdeExecutor +} = createListenerHook<{ order: any, task: any }>() + +/** Only for internal usage in this module */ +const orderHooksExecutors = { + beforePlaceOrder: beforePlaceOrderExecutor, + afterPlaceOrder: afterPlaceOrdeExecutor +} + +const orderHooks = { + /** Hook is fired directly before sending order to the server, after all client-side validations + * @param order Inside this function you have access to order object that you can access and modify. It should return order object. + */ + beforePlaceOrder: beforePlaceOrderHook, + /** Hook is fired right after order has been sent to server + * @param result `{ order, task }` task is a result of sending order to backend and order is order that has been sent there + */ + afterPlaceOrder: afterPlaceOrderHook +} + +export { + orderHooks, + orderHooksExecutors +} diff --git a/core/modules/order/hooks/beforeRegistration.ts b/core/modules/order/hooks/beforeRegistration.ts deleted file mode 100644 index 00c890193..000000000 --- a/core/modules/order/hooks/beforeRegistration.ts +++ /dev/null @@ -1,122 +0,0 @@ -import * as localForage from 'localforage' -import UniversalStorage from '@vue-storefront/core/store/lib/storage' -import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus/index' -import { Logger } from '@vue-storefront/core/lib/logger' -import { currentStoreView } from '@vue-storefront/core/lib/multistore'; -import rootStore from '@vue-storefront/core/store' -import i18n from '@vue-storefront/i18n' -import { serial, onlineHelper, processURLAddress } from '@vue-storefront/core/helpers' - -export function beforeRegistration ({ Vue, config, store, isServer }) { - Vue.prototype.$db.ordersCollection = new UniversalStorage(localForage.createInstance({ - name: 'shop', - storeName: 'orders', - driver: localForage[config.localForage.defaultDrivers['orders']] - })) - if (!isServer) { - const orderMutex = {} - // TODO: move to external file - EventBus.$on('order/PROCESS_QUEUE', async event => { - if (onlineHelper.isOnline) { - Logger.log('Sending out orders queue to server ...')() - - const storeView = currentStoreView() - const dbNamePrefix = storeView.storeCode ? storeView.storeCode + '-' : '' - - const ordersCollection = new UniversalStorage(localForage.createInstance({ - name: dbNamePrefix + 'shop', - storeName: 'orders', - driver: localForage[config.localForage.defaultDrivers['orders']] - })) - - const fetchQueue = [] - ordersCollection.iterate((order, id) => { - // Resulting key/value pair -- this callback - // will be executed for every item in the - // database. - - if (!order.transmited && !orderMutex[id]) { // not sent to the server yet - orderMutex[id] = true - const config = event.config - const orderData = order - const orderId = id - Logger.log('Pushing out order ' + orderId)() - fetchQueue.push( - /** @todo refactor order synchronisation to proper handling through vuex actions to avoid code duplication */ - fetch(processURLAddress(config.orders.endpoint), - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(orderData) - }).then(response => { - const contentType = response.headers.get('content-type') - if (contentType && contentType.includes('application/json')) { - return response.json() - } else { - orderMutex[id] = false - Logger.error('Error with response - bad content-type!')() - } - }) - .then(jsonResponse => { - if (jsonResponse) { - Logger.info('Response for: ' + orderId + ' = ' + JSON.stringify(jsonResponse.result))() - orderData.transmited = true // by default don't retry to transmit this order - orderData.transmited_at = new Date() - - if (jsonResponse.code !== 200) { - Logger.error(jsonResponse, 'order-sync')() - - if (jsonResponse.code === 400) { - rootStore.dispatch('notification/spawnNotification', { - type: 'error', - message: i18n.t('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: config.mailer.contactAddress }), - action1: { label: i18n.t('OK') } - }) - } else if (jsonResponse.code === 500 && jsonResponse.result === i18n.t('Error: Error while adding products')) { - rootStore.dispatch('notification/spawnNotification', { - type: 'error', - message: i18n.t('Some products you\'ve ordered are out of stock. Your order has been canceled.'), - action1: { label: i18n.t('OK') } - }) - } else { - orderData.transmited = false // probably some server related error. Enqueue - } - } - - ordersCollection.setItem(orderId.toString(), orderData) - } else { - Logger.error(jsonResponse)() - } - orderMutex[id] = false - }).catch(err => { - if (config.orders.offline_orders.notification.enabled) { - navigator.serviceWorker.ready.then(registration => { - registration.sync.register('orderSync') - .then(() => { - Logger.log('Order sync registered')() - }) - .catch(error => { - Logger.log('Unable to sync', error)() - }) - }) - } - Logger.error('Error sending order: ' + orderId, err)() - orderMutex[id] = false - }) - ) - } - }, (err) => { - if (err) Logger.error(err)() - Logger.log('Iteration has completed')() - - // execute them serially - serial(fetchQueue) - Logger.info('Processing orders queue has finished')() - }).catch(err => { - // This code runs if there were any errors - Logger.log(err)() - }) - } - }) - } -} diff --git a/core/modules/order/index.ts b/core/modules/order/index.ts index 9060e4e61..fcabee631 100644 --- a/core/modules/order/index.ts +++ b/core/modules/order/index.ts @@ -1,10 +1,115 @@ -import { createModule } from '@vue-storefront/core/lib/module' -import { module } from './store' -import { beforeRegistration } from './hooks/beforeRegistration' - -export const KEY = 'order' -export const Order = createModule({ - key: KEY, - store: { modules: [{ key: KEY, module }] }, - beforeRegistration -}) +import { orderStore } from './store' +import * as localForage from 'localforage' +import UniversalStorage from '@vue-storefront/core/lib/store/storage' +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus/index' +import { Logger } from '@vue-storefront/core/lib/logger' +import rootStore from '@vue-storefront/core/store' +import i18n from '@vue-storefront/i18n' +import { serial, onlineHelper, processURLAddress } from '@vue-storefront/core/helpers' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' +import { isServer } from '@vue-storefront/core/helpers' +import { StorefrontModule } from '@vue-storefront/core/lib/modules'; + +export const OrderModule: StorefrontModule = function ({store}) { + StorageManager.init('orders') + + if (!isServer) { + const orderMutex = {} + // TODO: move to external file + EventBus.$on('order/PROCESS_QUEUE', async event => { + if (onlineHelper.isOnline) { + Logger.log('Sending out orders queue to server ...')() + const ordersCollection = StorageManager.get('orders') + + const fetchQueue = [] + ordersCollection.iterate((order, id) => { + // Resulting key/value pair -- this callback + // will be executed for every item in the + // database. + + if (!order.transmited && !orderMutex[id]) { // not sent to the server yet + orderMutex[id] = true + const config = event.config + const orderData = order + const orderId = id + Logger.log('Pushing out order ' + orderId)() + fetchQueue.push( + /** @todo refactor order synchronisation to proper handling through vuex actions to avoid code duplication */ + fetch(processURLAddress(config.orders.endpoint), + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(orderData) + }).then(response => { + const contentType = response.headers.get('content-type') + if (contentType && contentType.includes('application/json')) { + return response.json() + } else { + orderMutex[id] = false + Logger.error('Error with response - bad content-type!')() + } + }) + .then(jsonResponse => { + if (jsonResponse) { + Logger.info('Response for: ' + orderId + ' = ' + JSON.stringify(jsonResponse.result))() + orderData.transmited = true // by default don't retry to transmit this order + orderData.transmited_at = new Date() + + if (jsonResponse.code !== 200) { + Logger.error(jsonResponse, 'order-sync')() + + if (jsonResponse.code === 400) { + rootStore.dispatch('notification/spawnNotification', { + type: 'error', + message: i18n.t('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: config.mailer.contactAddress }), + action1: { label: i18n.t('OK') } + }) + } else if (jsonResponse.code === 500 && jsonResponse.result === i18n.t('Error: Error while adding products')) { + rootStore.dispatch('notification/spawnNotification', { + type: 'error', + message: i18n.t('Some products you\'ve ordered are out of stock. Your order has been canceled.'), + action1: { label: i18n.t('OK') } + }) + } else { + orderData.transmited = false // probably some server related error. Enqueue + } + } + + ordersCollection.setItem(orderId.toString(), orderData) + } else { + Logger.error(jsonResponse)() + } + orderMutex[id] = false + }).catch(err => { + if (config.orders.offline_orders.notification.enabled) { + navigator.serviceWorker.ready.then(registration => { + registration.sync.register('orderSync') + .then(() => { + Logger.log('Order sync registered')() + }) + .catch(error => { + Logger.log('Unable to sync', error)() + }) + }) + } + Logger.error('Error sending order: ' + orderId, err)() + orderMutex[id] = false + }) + ) + } + }, (err) => { + if (err) Logger.error(err)() + Logger.log('Iteration has completed')() + + // execute them serially + serial(fetchQueue) + Logger.info('Processing orders queue has finished')() + }).catch(err => { + // This code runs if there were any errors + Logger.log(err)() + }) + } + }) + } + store.registerModule('order', orderStore) +} diff --git a/core/modules/order/store/actions.ts b/core/modules/order/store/actions.ts index cce27044a..145483c48 100644 --- a/core/modules/order/store/actions.ts +++ b/core/modules/order/store/actions.ts @@ -1,16 +1,19 @@ -import Vue from 'vue' +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' import * as types from './mutation-types' -import { currentStoreView } from '@vue-storefront/core/lib/multistore' import { ActionTree } from 'vuex' import RootState from '@vue-storefront/core/types/RootState' import OrderState from '../types/OrderState' import { Order } from '../types/Order' import { isOnline } from '@vue-storefront/core/lib/search' import i18n from '@vue-storefront/i18n' -import { TaskQueue } from '@vue-storefront/core/lib/sync' +import { OrderService } from '@vue-storefront/core/data-resolver' import { sha3_224 } from 'js-sha3' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' import { Logger } from '@vue-storefront/core/lib/logger' import config from 'config' +import { orderHooksExecutors } from '../hooks' +import * as entities from '@vue-storefront/core/lib/store/entities' +import { prepareOrder, optimizeOrder, notifications } from './../helpers' const actions: ActionTree = { /** @@ -18,80 +21,89 @@ const actions: ActionTree = { * @param {Object} commit method * @param {Order} order order data to be send */ - async placeOrder ({ commit, getters, dispatch }, order: Order) { + async placeOrder ({ commit, getters, dispatch }, newOrder: Order) { // Check if order is already processed/processing - const currentOrderHash = sha3_224(JSON.stringify(order)) + const optimizedOrder = optimizeOrder(newOrder) + const currentOrderHash = sha3_224(JSON.stringify(optimizedOrder)) const isAlreadyProcessed = getters.getSessionOrderHashes.includes(currentOrderHash) if (isAlreadyProcessed) return - commit(types.ORDER_ADD_SESSION_STAMPS, order) + commit(types.ORDER_ADD_SESSION_STAMPS, newOrder) commit(types.ORDER_ADD_SESSION_ORDER_HASH, currentOrderHash) + const preparedOrder = prepareOrder(optimizedOrder) - const storeView = currentStoreView() - if (storeView.storeCode) { - order.store_code = storeView.storeCode - } + EventBus.$emit('order-before-placed', { order: preparedOrder }) + const order = orderHooksExecutors.beforePlaceOrder(preparedOrder) - Vue.prototype.$bus.$emit('order-before-placed', { order: order }) if (!config.orders.directBackendSync || !isOnline()) { - commit(types.ORDER_PLACE_ORDER, order) - Vue.prototype.$bus.$emit('order-after-placed', { order: order }) - return { - resultCode: 200 - } - } else { - Vue.prototype.$bus.$emit('notification-progress-start', i18n.t('Processing order...')) - try { - const task: any = await TaskQueue.execute({ url: config.orders.endpoint, // sync the order - payload: { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - mode: 'cors', - body: JSON.stringify(order) - } - }) - Vue.prototype.$bus.$emit('notification-progress-stop') - - if (task.resultCode === 200) { - order.transmited = true - commit(types.ORDER_PLACE_ORDER, order) // archive this order but not trasmit it second time - commit(types.ORDER_LAST_ORDER_WITH_CONFIRMATION, { order: order, confirmation: task.result }) - Vue.prototype.$bus.$emit('order-after-placed', { order: order, confirmation: task.result }) - - return task - } else if (task.resultCode === 400) { - commit(types.ORDER_REMOVE_SESSION_ORDER_HASH, currentOrderHash) - - Logger.error('Internal validation error; Order entity is not compliant with the schema: ' + JSON.stringify(task.result), 'order')() - dispatch('notification/spawnNotification', { - type: 'error', - message: i18n.t('Internal validation error. Please check if all required fields are filled in. Please contact us on {email}', { email: config.mailer.contactAddress }), - action1: { label: i18n.t('OK') } - }, {root: true}) - - order.transmited = true // we don't want to enqueue it - commit(types.ORDER_PLACE_ORDER, order) // archive this order but not trasmit it second time - - return task - } - - throw new Error('Unhandled place order request error') - } catch (e) { // it is assummed that this is probably network/server side issue - commit(types.ORDER_REMOVE_SESSION_ORDER_HASH, currentOrderHash) - - dispatch('notification/spawnNotification', { - type: 'error', - message: i18n.t('The order can not be transfered because of server error. Order has been queued'), - action1: { label: i18n.t('OK') } - }, {root: true}) - - order.transmited = false // enqueue order - commit(types.ORDER_PLACE_ORDER, order) // archive this order and trasmit it next time the QUEUE is published - - Vue.prototype.$bus.$emit('notification-progress-stop') - - throw e - } + dispatch('enqueueOrder', { newOrder: order }) + EventBus.$emit('order-after-placed', { order }) + orderHooksExecutors.beforePlaceOrder({ order, task: { resultCode: 200 } }) + return { resultCode: 200 } + } + + EventBus.$emit('notification-progress-start', i18n.t('Processing order...')) + + try { + return dispatch('processOrder', { newOrder: order, currentOrderHash }) + } catch (error) { + dispatch('handlePlacingOrderFailed', { newOrder: order, currentOrderHash }) + throw error } + }, + async processOrder ({ commit, dispatch }, { newOrder, currentOrderHash }) { + const order = { ...newOrder, transmited: true } + const task = await OrderService.placeOrder(order) + EventBus.$emit('notification-progress-stop') + + if (task.resultCode === 200) { + dispatch('enqueueOrder', { newOrder: order }) + + commit(types.ORDER_LAST_ORDER_WITH_CONFIRMATION, { order, confirmation: task.result }) + orderHooksExecutors.afterPlaceOrder({ order, task }) + EventBus.$emit('order-after-placed', { order, confirmation: task.result }) + + return task + } + + if (task.resultCode === 400) { + commit(types.ORDER_REMOVE_SESSION_ORDER_HASH, currentOrderHash) + + Logger.error('Internal validation error; Order entity is not compliant with the schema: ' + JSON.stringify(task.result), 'orders')() + dispatch('notification/spawnNotification', notifications.internalValidationError(), { root: true }) + dispatch('enqueueOrder', { newOrder: order }) + + return task + } + + throw new Error('Unhandled place order request error') + }, + handlePlacingOrderFailed ({ commit, dispatch }, { newOrder, currentOrderHash }) { + const order = { newOrder, transmited: false } + commit(types.ORDER_REMOVE_SESSION_ORDER_HASH, currentOrderHash) + dispatch('notification/spawnNotification', notifications.orderCannotTransfered(), { root: true }) + dispatch('enqueueOrder', { newOrder: order }) + + EventBus.$emit('notification-progress-stop') + }, + enqueueOrder (context, { newOrder }) { + const orderId = entities.uniqueEntityId(newOrder) + const ordersCollection = StorageManager.get('orders') + const order = { + ...newOrder, + order_id: orderId.toString(), + created_at: new Date(), + updated_at: new Date() + } + + ordersCollection.setItem(orderId.toString(), order, (err, resp) => { + if (err) Logger.error(err, 'orders')() + + if (!order.transmited) { + EventBus.$emit('order/PROCESS_QUEUE', { config: config }) + } + + Logger.info('Order placed, orderId = ' + orderId, 'orders')() + }).catch((reason) => Logger.error(reason, 'orders')) } } diff --git a/core/modules/order/store/index.ts b/core/modules/order/store/index.ts index 4962392ff..51e22f6e1 100644 --- a/core/modules/order/store/index.ts +++ b/core/modules/order/store/index.ts @@ -5,7 +5,7 @@ import getters from './getters' import RootState from '@vue-storefront/core/types/RootState' import OrderState from '../types/OrderState' -export const module: Module = { +export const orderStore: Module = { namespaced: true, state: { last_order_confirmation: null, diff --git a/core/modules/order/store/mutation-types.ts b/core/modules/order/store/mutation-types.ts index bfa880b2a..d234039cc 100644 --- a/core/modules/order/store/mutation-types.ts +++ b/core/modules/order/store/mutation-types.ts @@ -1,5 +1,4 @@ -export const SN_ORDER = 'order' -export const ORDER_PLACE_ORDER = SN_ORDER + '/PLACE_ORDER' +export const SN_ORDER = 'orders' export const ORDER_PROCESS_QUEUE = SN_ORDER + '/PROCESS_QUEUE' export const ORDER_LAST_ORDER_WITH_CONFIRMATION = SN_ORDER + '/LAST_ORDER_CONFIRMATION' export const ORDER_ADD_SESSION_ORDER_HASH = SN_ORDER + '/ADD_SESSION_ORDER_HASH' diff --git a/core/modules/order/store/mutations.ts b/core/modules/order/store/mutations.ts index 680f1bc01..fa52d80e4 100644 --- a/core/modules/order/store/mutations.ts +++ b/core/modules/order/store/mutations.ts @@ -1,30 +1,10 @@ -import Vue from 'vue' import { MutationTree } from 'vuex' import * as types from './mutation-types' -import * as entities from '@vue-storefront/core/store/lib/entities' import OrderState from '../types/OrderState' import { Order } from '../types/Order' -import config from 'config' -import { Logger } from '@vue-storefront/core/lib/logger' +import * as entities from '@vue-storefront/core/lib/store/entities' const mutations: MutationTree = { - /** - * Add order to sync. queue - * @param {Object} product data format for products is described in /doc/ElasticSearch data formats.md - */ - [types.ORDER_PLACE_ORDER] (state, order) { - const ordersCollection = Vue.prototype.$db.ordersCollection - const orderId = order.order_id ? order.order_id : entities.uniqueEntityId(order).toString() - ordersCollection.setItem(orderId, order, (err, resp) => { - if (err) Logger.error(err, 'order')() - if (!order.transmited) { - Vue.prototype.$bus.$emit('order/PROCESS_QUEUE', { config: config }) // process checkout queue - } - Logger.info('Order placed, orderId = ' + orderId, 'order')() - }).catch((reason) => { - Logger.error(reason, 'order') // it doesn't work on SSR - }) // populate cache - }, [types.ORDER_LAST_ORDER_WITH_CONFIRMATION] (state, payload) { state.last_order_confirmation = payload }, diff --git a/core/modules/order/test/unit/helpers/optimizeOrder.spec.ts b/core/modules/order/test/unit/helpers/optimizeOrder.spec.ts new file mode 100644 index 000000000..cb6588271 --- /dev/null +++ b/core/modules/order/test/unit/helpers/optimizeOrder.spec.ts @@ -0,0 +1,108 @@ +import { Order } from '@vue-storefront/core/modules/order/types/Order' + +describe('optimizeOrder method', () => { + it('should return order without configurable_options and configurable_children', () => { + const expectedOrder: Order = { + order_id: 'orderId', + created_at: '10-29-2019', + updated_at: '11-29-2019', + transmited: true, + transmited_at: '10-29-2019', + status: 'pending', + state: 'pending', + user_id: '15', + cart_id: '20', + store_code: '2', + store_id: 2, + /** + * Products list + */ + products: [ + { + sku: 'sku1', + qty: 5, + name: 'Product 1', + price: 50, + product_type: 'Product type 1' + } + ], + addressInformation: { + shippingAddress: { + region: 'Region here', + region_id: 4, + country_id: '15', + /** + * Street name + */ + street: [], + company: 'Company here', + telephone: 'telephone', + postcode: 'postcode', + city: 'City name', + /** + * First name + */ + firstname: 'first name', + lastname: 'last name', + email: 'example@example.com', + region_code: '20', + sameAsBilling: 1 + }, + billingAddress: { + properties: {} + }, + shipping_method_code: 'one', + shipping_carrier_code: 'two', + payment_method_code: 'three', + payment_method_additional: 'four' + } + }; + const optimizedOrder: Order = { + order_id: 'orderId', + created_at: '10-29-2019', + updated_at: '11-29-2019', + transmited: true, + transmited_at: '10-29-2019', + status: 'pending', + state: 'pending', + user_id: '15', + cart_id: '20', + store_code: '2', + store_id: 2, + products: + [{ + sku: 'sku1', + qty: 5, + name: 'Product 1', + price: 50, + product_type: 'Product type 1' + }], + addressInformation: + { + shippingAddress: + { + region: 'Region here', + region_id: 4, + country_id: '15', + street: [], + company: 'Company here', + telephone: 'telephone', + postcode: 'postcode', + city: 'City name', + firstname: 'first name', + lastname: 'last name', + email: 'example@example.com', + region_code: '20', + sameAsBilling: 1 + }, + billingAddress: { properties: {} }, + shipping_method_code: 'one', + shipping_carrier_code: 'two', + payment_method_code: 'three', + payment_method_additional: 'four' + } + } + + expect(optimizedOrder).toEqual(expectedOrder) + }) +}); diff --git a/core/modules/order/test/unit/helpers/prepareOrder.spec.ts b/core/modules/order/test/unit/helpers/prepareOrder.spec.ts new file mode 100644 index 000000000..6d3fb0c04 --- /dev/null +++ b/core/modules/order/test/unit/helpers/prepareOrder.spec.ts @@ -0,0 +1,1857 @@ +import { Order } from '@vue-storefront/core/modules/order/types/Order' + +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(() => ({ + storeCode: '2' + })) +})); + +describe('prepareOrder method', () => { + it('should return order', () => { + const result: Order = { + order_id: 'orderId', + created_at: '10-29-2019', + updated_at: '11-29-2019', + transmited: true, + transmited_at: '10-29-2019', + status: 'pending', + state: 'pending', + user_id: '15', + cart_id: '20', + store_code: '2', + store_id: 2, + products: + [{ + sku: 'sku1', + qty: 5, + name: 'Product 1', + price: 50, + product_type: 'Product type 1', + configurable_options: [ + { + 'attribute_id': '93', + 'values': [ + { + 'value_index': 49 + }, + { + 'value_index': 52 + }, + { + 'value_index': 56 + } + ], + 'product_id': 19, + 'id': 3, + 'label': 'Color', + 'position': 0 + }, + { + 'attribute_id': '157', + 'values': [ + { + 'value_index': 167 + }, + { + 'value_index': 168 + }, + { + 'value_index': 169 + }, + { + 'value_index': 170 + }, + { + 'value_index': 171 + } + ], + 'product_id': 19, + 'id': 2, + 'label': 'Size', + 'position': 0 + } + ], + configurable_children: [ + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XS-Black', + 'sku': 'MH01-XS-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '167', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xs-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XS-Gray', + 'sku': 'MH01-XS-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '167', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xs-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XS-Orange', + 'sku': 'MH01-XS-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '167', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xs-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-S-Black', + 'sku': 'MH01-S-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '168', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-s-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-S-Gray', + 'sku': 'MH01-S-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '168', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-s-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-S-Orange', + 'sku': 'MH01-S-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '168', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-s-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-M-Black', + 'sku': 'MH01-M-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '169', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-m-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-M-Gray', + 'sku': 'MH01-M-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '169', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-m-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-M-Orange', + 'sku': 'MH01-M-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '169', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-m-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-L-Black', + 'sku': 'MH01-L-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '170', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-l-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-L-Gray', + 'sku': 'MH01-L-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '170', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-l-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-L-Orange', + 'sku': 'MH01-L-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '170', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-l-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XL-Black', + 'sku': 'MH01-XL-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '171', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xl-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XL-Gray', + 'sku': 'MH01-XL-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '171', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xl-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XL-Orange', + 'sku': 'MH01-XL-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '171', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xl-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + } + ] + }], + addressInformation: + { + shippingAddress: + { + region: 'Region here', + region_id: 4, + country_id: '15', + street: [], + company: 'Company here', + telephone: 'telephone', + postcode: 'postcode', + city: 'City name', + firstname: 'first name', + lastname: 'last name', + email: 'example@example.com', + region_code: '20', + sameAsBilling: 1 + }, + billingAddress: { properties: {} }, + shipping_method_code: 'one', + shipping_carrier_code: 'two', + payment_method_code: 'three', + payment_method_additional: 'four' + } + }; + const expectedOrder: Order = { + order_id: 'orderId', + created_at: '10-29-2019', + updated_at: '11-29-2019', + transmited: true, + transmited_at: '10-29-2019', + status: 'pending', + state: 'pending', + user_id: '15', + cart_id: '20', + store_code: '2', + store_id: 2, + /** + * Products list + */ + products: [ + { + sku: 'sku1', + qty: 5, + name: 'Product 1', + price: 50, + product_type: 'Product type 1', + configurable_options: [ + { + 'attribute_id': '93', + 'values': [ + { + 'value_index': 49 + }, + { + 'value_index': 52 + }, + { + 'value_index': 56 + } + ], + 'product_id': 19, + 'id': 3, + 'label': 'Color', + 'position': 0 + }, + { + 'attribute_id': '157', + 'values': [ + { + 'value_index': 167 + }, + { + 'value_index': 168 + }, + { + 'value_index': 169 + }, + { + 'value_index': 170 + }, + { + 'value_index': 171 + } + ], + 'product_id': 19, + 'id': 2, + 'label': 'Size', + 'position': 0 + } + ], + configurable_children: [ + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XS-Black', + 'sku': 'MH01-XS-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '167', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xs-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XS-Gray', + 'sku': 'MH01-XS-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '167', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xs-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XS-Orange', + 'sku': 'MH01-XS-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '167', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xs-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-S-Black', + 'sku': 'MH01-S-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '168', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-s-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-S-Gray', + 'sku': 'MH01-S-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '168', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-s-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-S-Orange', + 'sku': 'MH01-S-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '168', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-s-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-M-Black', + 'sku': 'MH01-M-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '169', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-m-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-M-Gray', + 'sku': 'MH01-M-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '169', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-m-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-M-Orange', + 'sku': 'MH01-M-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '169', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-m-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-L-Black', + 'sku': 'MH01-L-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '170', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-l-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-L-Gray', + 'sku': 'MH01-L-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '170', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-l-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-L-Orange', + 'sku': 'MH01-L-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '170', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-l-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XL-Black', + 'sku': 'MH01-XL-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '171', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xl-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XL-Gray', + 'sku': 'MH01-XL-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '171', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xl-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XL-Orange', + 'sku': 'MH01-XL-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '171', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xl-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + } + ] + } + ], + addressInformation: { + shippingAddress: { + region: 'Region here', + region_id: 4, + country_id: '15', + /** + * Street name + */ + street: [], + company: 'Company here', + telephone: 'telephone', + postcode: 'postcode', + city: 'City name', + /** + * First name + */ + firstname: 'first name', + lastname: 'last name', + email: 'example@example.com', + region_code: '20', + sameAsBilling: 1 + }, + billingAddress: { + properties: { + } + }, + shipping_method_code: 'one', + shipping_carrier_code: 'two', + payment_method_code: 'three', + payment_method_additional: 'four' + } + }; + + expect(result).toEqual(expectedOrder); + }) +}); diff --git a/core/modules/order/test/unit/store/actions.spec.ts b/core/modules/order/test/unit/store/actions.spec.ts new file mode 100644 index 000000000..1955db08a --- /dev/null +++ b/core/modules/order/test/unit/store/actions.spec.ts @@ -0,0 +1,1234 @@ +import * as types from '../../../store/mutation-types'; +import orderActions from '../../../store/actions'; +import { createContextMock } from '@vue-storefront/unit-tests/utils'; +import { notifications } from '../../../helpers'; +import { Order } from '../../../types/Order'; +import { OrderService } from '@vue-storefront/core/data-resolver' +import config from 'config'; + +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('@vue-storefront/core/app', () => jest.fn()) +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(() => ({ + storeCode: '2', + localizedRoute: jest.fn() + })) +})); +jest.mock('@vue-storefront/core/data-resolver', () => ({ + OrderService: { + placeOrder: jest.fn() + } +})); +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + log: jest.fn(() => () => { }), + debug: jest.fn(() => () => { }), + warn: jest.fn(() => () => { }), + error: jest.fn(() => () => { }), + info: jest.fn(() => () => { }) + } +})); + +let order: Order; +let task: any; +let currentOrderHash: string; + +describe('Order actions', () => { + beforeEach(() => { + jest.clearAllMocks(); + order = { + order_id: 'orderId', + created_at: '10-29-2019', + updated_at: '11-29-2019', + transmited: true, + transmited_at: '10-29-2019', + status: 'pending', + state: 'pending', + user_id: '15', + cart_id: '20', + store_code: '2', + store_id: 2, + /** + * Products list + */ + products: [ + { + sku: 'sku1', + qty: 5, + name: 'Product 1', + price: 50, + product_type: 'Product type 1', + configurable_options: [ + { + 'attribute_id': '93', + 'values': [ + { + 'value_index': 49 + }, + { + 'value_index': 52 + }, + { + 'value_index': 56 + } + ], + 'product_id': 19, + 'id': 3, + 'label': 'Color', + 'position': 0 + }, + { + 'attribute_id': '157', + 'values': [ + { + 'value_index': 167 + }, + { + 'value_index': 168 + }, + { + 'value_index': 169 + }, + { + 'value_index': 170 + }, + { + 'value_index': 171 + } + ], + 'product_id': 19, + 'id': 2, + 'label': 'Size', + 'position': 0 + } + ], + configurable_children: [ + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XS-Black', + 'sku': 'MH01-XS-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '167', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xs-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XS-Gray', + 'sku': 'MH01-XS-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '167', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xs-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XS-Orange', + 'sku': 'MH01-XS-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '167', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xs-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-S-Black', + 'sku': 'MH01-S-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '168', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-s-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-S-Gray', + 'sku': 'MH01-S-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '168', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-s-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-S-Orange', + 'sku': 'MH01-S-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '168', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-s-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-M-Black', + 'sku': 'MH01-M-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '169', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-m-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-M-Gray', + 'sku': 'MH01-M-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '169', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-m-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-M-Orange', + 'sku': 'MH01-M-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '169', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-m-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-L-Black', + 'sku': 'MH01-L-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '170', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-l-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-L-Gray', + 'sku': 'MH01-L-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '170', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-l-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-L-Orange', + 'sku': 'MH01-L-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '170', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-l-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XL-Black', + 'sku': 'MH01-XL-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '171', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xl-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XL-Gray', + 'sku': 'MH01-XL-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '171', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xl-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XL-Orange', + 'sku': 'MH01-XL-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '171', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xl-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + } + ] + } + ], + addressInformation: { + shippingAddress: { + region: 'Region here', + region_id: 4, + country_id: '15', + /** + * Street name + */ + street: [], + company: 'Company here', + telephone: 'telephone', + postcode: 'postcode', + city: 'City name', + /** + * First name + */ + firstname: 'first name', + lastname: 'last name', + email: 'example@example.com', + region_code: '20', + sameAsBilling: 1 + }, + billingAddress: { + properties: { + } + }, + shipping_method_code: 'one', + shipping_carrier_code: 'two', + payment_method_code: 'three', + payment_method_additional: 'four' + } + } + task = { resultCode: 200, result: 'server-order-token' } + currentOrderHash = '4884598394f87665bceddb7585d5d7c5b08b6e0eb6a3ebaf6710fc48' + }); + + describe('placeOrder action', () => { + it('should not add session stamps if it is alrady processed', async () => { + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: { getSessionOrderHashes: 'current-order-hash' } + }; + + await (orderActions as any).placeOrder(contextMock, order); + + expect(contextMock.commit).not.toBeCalledWith(types.ORDER_ADD_SESSION_STAMPS); + }) + + it('should dispatch enqueueOrder', async () => { + const contextMock = createContextMock({ + getters: { getSessionOrderHashes: 'current-order-hash' } + }); + const newOrder: Order = { + order_id: 'orderId', + created_at: '10-29-2019', + updated_at: '11-29-2019', + transmited: true, + transmited_at: '10-29-2019', + status: 'pending', + state: 'pending', + user_id: '15', + cart_id: '20', + store_code: '2', + store_id: 2, + products: + [{ + sku: 'sku1', + qty: 5, + name: 'Product 1', + price: 50, + product_type: 'Product type 1' + }], + addressInformation: + { + shippingAddress: + { + region: 'Region here', + region_id: 4, + country_id: '15', + street: [], + company: 'Company here', + telephone: 'telephone', + postcode: 'postcode', + city: 'City name', + firstname: 'first name', + lastname: 'last name', + email: 'example@example.com', + region_code: '20', + sameAsBilling: 1 + }, + billingAddress: { properties: {} }, + shipping_method_code: 'one', + shipping_carrier_code: 'two', + payment_method_code: 'three', + payment_method_additional: 'four' + } + } + + config.orders = { + directBackendSync: false + } + + await (orderActions as any).placeOrder(contextMock, order) + + expect(contextMock.dispatch).toBeCalledWith('enqueueOrder', { newOrder: newOrder }) + }) + + it('should dispatch processOrder', async () => { + (OrderService.placeOrder as jest.Mock).mockImplementation(async () => + (task) + ); + const newOrder: Order = { + order_id: 'orderId', + created_at: '10-29-2019', + updated_at: '11-29-2019', + transmited: true, + transmited_at: '10-29-2019', + status: 'pending', + state: 'pending', + user_id: '15', + cart_id: '20', + store_code: '2', + store_id: 2, + products: + [{ + sku: 'sku1', + qty: 5, + name: 'Product 1', + price: 50, + product_type: 'Product type 1' + }], + addressInformation: + { + shippingAddress: + { + region: 'Region here', + region_id: 4, + country_id: '15', + street: [], + company: 'Company here', + telephone: 'telephone', + postcode: 'postcode', + city: 'City name', + firstname: 'first name', + lastname: 'last name', + email: 'example@example.com', + region_code: '20', + sameAsBilling: 1 + }, + billingAddress: { properties: {} }, + shipping_method_code: 'one', + shipping_carrier_code: 'two', + payment_method_code: 'three', + payment_method_additional: 'four' + } + } + const contextMock = createContextMock({ + getters: { getSessionOrderHashes: 'current-order-hash' } + }); + config.orders = { + directBackendSync: true + } + + await (orderActions as any).placeOrder(contextMock, order) + + expect(contextMock.commit).toBeCalledWith(types.ORDER_ADD_SESSION_STAMPS, order); + expect(contextMock.dispatch).toBeCalledWith('processOrder', { newOrder: newOrder, currentOrderHash }) + }) + }); + describe('processOrder action', () => { + it('should add last order with confirmation', async () => { + (OrderService.placeOrder as jest.Mock).mockImplementation(async () => + (task) + ); + const contextMock = createContextMock(); + const order = {'transmited': true} + const order1 = { + order_id: 'orderId', + created_at: '10-29-2019', + updated_at: '11-29-2019', + transmited: true, + transmited_at: '10-29-2019', + status: 'pending', + state: 'pending', + user_id: '15', + cart_id: '20', + store_code: '2', + store_id: 2, + products: + [{ + sku: 'sku1', + qty: 5, + name: 'Product 1', + price: 50, + product_type: 'Product type 1' + }], + addressInformation: + { + shippingAddress: + { + region: 'Region here', + region_id: 4, + country_id: '15', + street: [], + company: 'Company here', + telephone: 'telephone', + postcode: 'postcode', + city: 'City name', + firstname: 'first name', + lastname: 'last name', + email: 'example@example.com', + region_code: '20', + sameAsBilling: 1 + }, + billingAddress: { properties: {} }, + shipping_method_code: 'one', + shipping_carrier_code: 'two', + payment_method_code: 'three', + payment_method_additional: 'four' + } + } + const wrapper = (actions: any) => actions.processOrder(contextMock, { order1, currentOrderHash }) + const processOrderAction = await wrapper(orderActions); + + expect(contextMock.commit).toBeCalledWith(types.ORDER_LAST_ORDER_WITH_CONFIRMATION, { order, confirmation: task.result }) + expect(processOrderAction).toEqual(task) + }) + + it('should remove session order hash', async () => { + task = { resultCode: 400, result: 'server-order-token' }; + (OrderService.placeOrder as jest.Mock).mockImplementation(async () => + (task) + ); + const contextMock = createContextMock(); + const wrapper = (actions: any) => actions.processOrder(contextMock, { order, currentOrderHash }) + const processOrderAction = await wrapper(orderActions); + + expect(contextMock.commit).toBeCalledWith(types.ORDER_REMOVE_SESSION_ORDER_HASH, currentOrderHash); + expect(processOrderAction).toEqual(task) + }) + }); + + describe('handlePlacingOrderFailed action', () => { + it('should dispatch enqueue action', () => { + const contextMock = createContextMock(); + const newOrder: Order = { + order_id: 'orderId', + created_at: '10-29-2019', + updated_at: '11-29-2019', + transmited: true, + transmited_at: '10-29-2019', + status: 'pending', + state: 'pending', + user_id: '15', + cart_id: '20', + store_code: '2', + store_id: 2, + products: + [{ + sku: 'sku1', + qty: 5, + name: 'Product 1', + price: 50, + product_type: 'Product type 1' + }], + addressInformation: + { + shippingAddress: + { + region: 'Region here', + region_id: 4, + country_id: '15', + street: [], + company: 'Company here', + telephone: 'telephone', + postcode: 'postcode', + city: 'City name', + firstname: 'first name', + lastname: 'last name', + email: 'example@example.com', + region_code: '20', + sameAsBilling: 1 + }, + billingAddress: { properties: {} }, + shipping_method_code: 'one', + shipping_carrier_code: 'two', + payment_method_code: 'three', + payment_method_additional: 'four' + } + } + const expectedOrder = { newOrder, transmited: false } + + const wrapper = (orderActions: any) => orderActions.handlePlacingOrderFailed(contextMock, { newOrder, currentOrderHash }); + + wrapper(orderActions); + + expect(contextMock.dispatch).toBeCalledWith('enqueueOrder', { newOrder: expectedOrder }) + }) + }) +}); diff --git a/core/modules/order/test/unit/store/mutations.spec.ts b/core/modules/order/test/unit/store/mutations.spec.ts new file mode 100644 index 000000000..8dcfeb011 --- /dev/null +++ b/core/modules/order/test/unit/store/mutations.spec.ts @@ -0,0 +1,1926 @@ +import { orderStore } from '../../../store'; +import * as types from '../../../store/mutation-types'; +import { Order } from '../../../types/Order' + +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('@vue-storefront/core/app', () => jest.fn()) +jest.mock('@vue-storefront/core/lib/multistore', () => jest.fn()) +jest.mock('@vue-storefront/core/lib/storage-manager', () => jest.fn()) + +describe('Order mutations', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('ORDER_ADD_SESSION_ORDER_HASH', () => { + it('adds session order hash', () => { + const stateMock = { + session_order_hashes: [] + } + const session_order_hash = 'session-order-hash' + const expectedState = { + session_order_hashes: [ + 'session-order-hash' + ] + } + const wrapper = (mutations: any) => mutations[types.ORDER_ADD_SESSION_ORDER_HASH](stateMock, session_order_hash) + + wrapper(orderStore.mutations) + + expect(stateMock).toEqual(expectedState) + }) + }) + + describe('ORDER_LAST_ORDER_WITH_CONFIRMATION', () => { + it('adds last order with confirmation', () => { + const stateMock = { + last_order_confirmation: 1, + session_order_hashes: 2 + } + const order: Order = { + order_id: 'orderId', + created_at: '10-29-2019', + updated_at: '11-29-2019', + transmited: true, + transmited_at: '10-29-2019', + status: 'pending', + state: 'pending', + user_id: '15', + cart_id: '20', + store_code: '2', + store_id: 2, + /** + * Products list + */ + products: [ + { + sku: 'sku1', + qty: 5, + name: 'Product 1', + price: 50, + product_type: 'Product type 1', + configurable_options: [ + { + 'attribute_id': '93', + 'values': [ + { + 'value_index': 49 + }, + { + 'value_index': 52 + }, + { + 'value_index': 56 + } + ], + 'product_id': 19, + 'id': 3, + 'label': 'Color', + 'position': 0 + }, + { + 'attribute_id': '157', + 'values': [ + { + 'value_index': 167 + }, + { + 'value_index': 168 + }, + { + 'value_index': 169 + }, + { + 'value_index': 170 + }, + { + 'value_index': 171 + } + ], + 'product_id': 19, + 'id': 2, + 'label': 'Size', + 'position': 0 + } + ], + configurable_children: [ + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XS-Black', + 'sku': 'MH01-XS-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '167', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xs-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XS-Gray', + 'sku': 'MH01-XS-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '167', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xs-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XS-Orange', + 'sku': 'MH01-XS-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '167', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xs-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-S-Black', + 'sku': 'MH01-S-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '168', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-s-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-S-Gray', + 'sku': 'MH01-S-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '168', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-s-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-S-Orange', + 'sku': 'MH01-S-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '168', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-s-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-M-Black', + 'sku': 'MH01-M-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '169', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-m-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-M-Gray', + 'sku': 'MH01-M-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '169', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-m-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-M-Orange', + 'sku': 'MH01-M-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '169', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-m-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-L-Black', + 'sku': 'MH01-L-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '170', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-l-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-L-Gray', + 'sku': 'MH01-L-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '170', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-l-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-L-Orange', + 'sku': 'MH01-L-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '170', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-l-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XL-Black', + 'sku': 'MH01-XL-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '171', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xl-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XL-Gray', + 'sku': 'MH01-XL-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '171', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xl-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XL-Orange', + 'sku': 'MH01-XL-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '171', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xl-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + } + ] + } + ], + addressInformation: { + shippingAddress: { + region: 'Region here', + region_id: 4, + country_id: '15', + /** + * Street name + */ + street: [], + company: 'Company here', + telephone: 'telephone', + postcode: 'postcode', + city: 'City name', + /** + * First name + */ + firstname: 'first name', + lastname: 'last name', + email: 'example@example.com', + region_code: '20', + sameAsBilling: 1 + }, + billingAddress: { + properties: { + } + }, + shipping_method_code: 'one', + shipping_carrier_code: 'two', + payment_method_code: 'three', + payment_method_additional: 'four' + } + } + const expectedOrder: Order = { + order_id: 'orderId', + created_at: '10-29-2019', + updated_at: '11-29-2019', + transmited: true, + transmited_at: '10-29-2019', + status: 'pending', + state: 'pending', + user_id: '15', + cart_id: '20', + store_code: '2', + store_id: 2, + /** + * Products list + */ + products: [ + { + sku: 'sku1', + qty: 5, + name: 'Product 1', + price: 50, + product_type: 'Product type 1', + configurable_options: [ + { + 'attribute_id': '93', + 'values': [ + { + 'value_index': 49 + }, + { + 'value_index': 52 + }, + { + 'value_index': 56 + } + ], + 'product_id': 19, + 'id': 3, + 'label': 'Color', + 'position': 0 + }, + { + 'attribute_id': '157', + 'values': [ + { + 'value_index': 167 + }, + { + 'value_index': 168 + }, + { + 'value_index': 169 + }, + { + 'value_index': 170 + }, + { + 'value_index': 171 + } + ], + 'product_id': 19, + 'id': 2, + 'label': 'Size', + 'position': 0 + } + ], + configurable_children: [ + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XS-Black', + 'sku': 'MH01-XS-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '167', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xs-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XS-Gray', + 'sku': 'MH01-XS-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '167', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xs-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XS-Orange', + 'sku': 'MH01-XS-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '167', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xs-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-S-Black', + 'sku': 'MH01-S-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '168', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-s-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-S-Gray', + 'sku': 'MH01-S-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '168', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-s-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-S-Orange', + 'sku': 'MH01-S-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '168', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-s-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-M-Black', + 'sku': 'MH01-M-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '169', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-m-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-M-Gray', + 'sku': 'MH01-M-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '169', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-m-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-M-Orange', + 'sku': 'MH01-M-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '169', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-m-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-L-Black', + 'sku': 'MH01-L-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '170', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-l-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-L-Gray', + 'sku': 'MH01-L-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '170', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-l-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-L-Orange', + 'sku': 'MH01-L-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '170', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-l-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XL-Black', + 'sku': 'MH01-XL-Black', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '171', + 'attribute_code': 'size' + }, + { + 'value': '49', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-black_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xl-black', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XL-Gray', + 'sku': 'MH01-XL-Gray', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '171', + 'attribute_code': 'size' + }, + { + 'value': '52', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-gray_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xl-gray', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + }, + { + 'price': 52, + 'name': 'Chaz Kangeroo Hoodie-XL-Orange', + 'sku': 'MH01-XL-Orange', + 'custom_attributes': [ + { + 'value': '0', + 'attribute_code': 'required_options' + }, + { + 'value': '0', + 'attribute_code': 'has_options' + }, + { + 'value': '2', + 'attribute_code': 'tax_class_id' + }, + { + 'value': [ + '15', + '36', + '2' + ], + 'attribute_code': 'category_ids' + }, + { + 'value': '171', + 'attribute_code': 'size' + }, + { + 'value': '56', + 'attribute_code': 'color' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'small_image' + }, + { + 'value': '/m/h/mh01-orange_main.jpg', + 'attribute_code': 'thumbnail' + }, + { + 'value': 'chaz-kangeroo-hoodie-xl-orange', + 'attribute_code': 'url_key' + }, + { + 'value': '0', + 'attribute_code': 'msrp_display_actual_price_type' + } + ] + } + ] + } + ], + addressInformation: { + shippingAddress: { + region: 'Region here', + region_id: 4, + country_id: '15', + /** + * Street name + */ + street: [], + company: 'Company here', + telephone: 'telephone', + postcode: 'postcode', + city: 'City name', + /** + * First name + */ + firstname: 'first name', + lastname: 'last name', + email: 'example@example.com', + region_code: '20', + sameAsBilling: 1 + }, + billingAddress: { + properties: { + } + }, + shipping_method_code: 'one', + shipping_carrier_code: 'two', + payment_method_code: 'three', + payment_method_additional: 'four' + } + } + const wrapper = (mutations: any) => mutations[types.ORDER_LAST_ORDER_WITH_CONFIRMATION](stateMock, order) + + wrapper(orderStore.mutations) + + expect(order).toEqual(expectedOrder) + }) + }); + + describe('ORDER_REMOVE_SESSION_ORDER_HASH', () => { + it('removes session order hash', () => { + const stateMock = { + session_order_hashes: [ + 'session-order-hash-one', + 'session-order-hash-two', + 'session-order-hash-three' + ] + } + const session_order_hash_two = 'session-order-hash-two' + const expectedState = { + session_order_hashes: [ + 'session-order-hash-one', + 'session-order-hash-three' + ] + } + const wrapper = (mutations: any) => mutations[types.ORDER_REMOVE_SESSION_ORDER_HASH](stateMock, session_order_hash_two) + + wrapper(orderStore.mutations) + + expect(stateMock).toEqual(expectedState) + }) + }) + +}); diff --git a/core/modules/order/types/Order.ts b/core/modules/order/types/Order.ts index e85fa0753..3fa062073 100644 --- a/core/modules/order/types/Order.ts +++ b/core/modules/order/types/Order.ts @@ -9,7 +9,7 @@ export interface Order { user_id?: string, cart_id?: string, store_code?: string, - store_id?: number, + store_id?: number | string, /** * Products list */ @@ -26,7 +26,7 @@ export interface Order { addressInformation: { shippingAddress?: { region?: string, - region_id?: number, + region_id?: number | string, country_id?: string, /** * Street name diff --git a/core/modules/recently-viewed/hooks/afterRegistration.ts b/core/modules/recently-viewed/hooks/afterRegistration.ts deleted file mode 100644 index c44753d23..000000000 --- a/core/modules/recently-viewed/hooks/afterRegistration.ts +++ /dev/null @@ -1,4 +0,0 @@ -// 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 }) { - if (!isServer) store.dispatch('recently-viewed/load') -} diff --git a/core/modules/recently-viewed/index.ts b/core/modules/recently-viewed/index.ts index 49e45b0f8..7538ef3c7 100644 --- a/core/modules/recently-viewed/index.ts +++ b/core/modules/recently-viewed/index.ts @@ -1,13 +1,14 @@ -import { module } from './store' +import { recentlyViewedStore } from './store' import { plugin } from './store/plugin' -import { createModule } from '@vue-storefront/core/lib/module' -import { initCacheStorage } from '@vue-storefront/core/helpers/initCacheStorage' -import { afterRegistration } from './hooks/afterRegistration' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' +import { StorefrontModule } from '@vue-storefront/core/lib/modules'; +import { isServer } from '@vue-storefront/core/helpers' -export const KEY = 'recently-viewed' -export const cacheStorage = initCacheStorage(KEY) -export const RecentlyViewed = createModule({ - key: KEY, - store: { modules: [{ key: KEY, module }], plugin }, - afterRegistration -}) +export const cacheStorage = StorageManager.init('recently-viewed') + +export const RecentlyViewedModule: StorefrontModule = function ({store}) { + store.registerModule('recently-viewed', recentlyViewedStore) + store.subscribe(plugin) + + if (!isServer) store.dispatch('recently-viewed/load') +} diff --git a/core/modules/recently-viewed/store/index.ts b/core/modules/recently-viewed/store/index.ts index 4db935d94..69b73eaf5 100644 --- a/core/modules/recently-viewed/store/index.ts +++ b/core/modules/recently-viewed/store/index.ts @@ -4,7 +4,7 @@ import mutations from './mutations' import RootState from '@vue-storefront/core/types/RootState' import RecentlyViewedState from '../types/RecentlyViewedState' -export const module: Module = { +export const recentlyViewedStore: Module = { namespaced: true, state: { items: [] diff --git a/core/modules/recently-viewed/test/unit/actions.spec.ts b/core/modules/recently-viewed/test/unit/actions.spec.ts new file mode 100644 index 000000000..92ab857fc --- /dev/null +++ b/core/modules/recently-viewed/test/unit/actions.spec.ts @@ -0,0 +1,61 @@ +import * as types from '../../store/mutation-types'; +import recentlyViewedActions from '../../store/actions'; +import { cacheStorage } from '@vue-storefront/core/modules/recently-viewed/index' + +jest.mock('@vue-storefront/core/modules/recently-viewed/index', () => ({ + cacheStorage: { + getItem: jest.fn() + } +})); + +let product + +describe('RecentlyViewed actions', () => { + beforeEach(() => { + jest.clearAllMocks(); + product = {id: 'xyz'}; + }); + + describe('addItem', () => { + it('should add recently viewed item', () => { + const contextMock = { + commit: jest.fn() + }; + const wrapper = (actions: any) => actions.addItem(contextMock, product); + + wrapper(recentlyViewedActions); + + expect(contextMock.commit).toBeCalledWith(types.RECENTLY_VIEWED_ADD_ITEM, { product }); + }); + }); + + describe('load', () => { + it('should add storedItems from cache', () => { + const contextMock = { + commit: jest.fn() + }; + const wrapper = (actions: any) => actions.load(contextMock); + + cacheStorage.getItem.mockImplementationOnce( + jest.fn((cacheType, callback) => callback(null, [product])) + ) + + wrapper(recentlyViewedActions); + + expect(contextMock.commit).toBeCalledWith(types.RECENTLY_VIEWED_LOAD, [product]); + }); + + it('should throw error if there is a problem while loading storedItems', () => { + const contextMock = { + commit: jest.fn() + }; + const wrapper = (actions: any) => actions.load(contextMock); + + cacheStorage.getItem.mockImplementationOnce( + jest.fn((cacheType, callback) => callback(new Error('test'), [product])) + ); + + expect(wrapper.bind(null, recentlyViewedActions)).toThrowError('test'); + }); + }); +}) diff --git a/core/modules/recently-viewed/test/unit/mutations.spec.ts b/core/modules/recently-viewed/test/unit/mutations.spec.ts new file mode 100644 index 000000000..fee3615b3 --- /dev/null +++ b/core/modules/recently-viewed/test/unit/mutations.spec.ts @@ -0,0 +1,86 @@ +import * as types from '../../store/mutation-types' +import recentlyViewedMutations from '../../store/mutations' + +describe('RecentlyViewed mutations', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('RECENTLY_VIEWED_ADD_ITEM', () => { + it('adds a product to recently viewed if none of its sku is there yet', () => { + const stateMock = { + items: [] + } + const product = { + qty: 123, + sku: 'foo' + } + const expectedState = { + items: [ + { + qty: 123, + sku: 'foo' + } + ] + } + const wrapper = (mutations: any) => mutations[types.RECENTLY_VIEWED_ADD_ITEM](stateMock, { product }) + + wrapper(recentlyViewedMutations) + + expect(stateMock).toEqual(expectedState) + }) + + it('don\'t adds a product to recently viewed if there is one with the same sku', () => { + const stateMock = { + items: [ + { + qty: 123, + sku: 'foo' + } + ] + } + const product = { + qty: 123, + sku: 'foo' + } + const expectedState = { + items: [ + { + qty: 123, + sku: 'foo' + } + ] + } + const wrapper = (mutations: any) => mutations[types.RECENTLY_VIEWED_ADD_ITEM](stateMock, { product }) + + wrapper(recentlyViewedMutations) + + expect(stateMock).toEqual(expectedState) + }) + }) + + describe('RECENTLY_VIEWED_LOAD', () => { + it('loads recently viewed items with given products', () => { + const stateMock = { + items: [] + } + const product = { + qty: 123, + sku: 'foo' + } + const expectedState = { + items: [ + { + qty: 123, + sku: 'foo' + } + ] + } + const wrapper = (mutations: any) => mutations[types.RECENTLY_VIEWED_LOAD](stateMock, [ product ]) + + wrapper(recentlyViewedMutations) + + expect(stateMock).toEqual(expectedState) + }) + }) +}) diff --git a/core/modules/review/helpers/createLoadReviewsQuery.ts b/core/modules/review/helpers/createLoadReviewsQuery.ts new file mode 100644 index 000000000..73c431828 --- /dev/null +++ b/core/modules/review/helpers/createLoadReviewsQuery.ts @@ -0,0 +1,16 @@ +import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' + +const createLoadReviewsQuery = ({ productId, approved }) => { + let query = new SearchQuery() + + if (productId) { + query = query.applyFilter({key: 'product_id', value: {'eq': productId}}) + } + + if (approved) { + query = query.applyFilter({key: 'review_status', value: {'eq': 1}}) + } + + return query +} +export default createLoadReviewsQuery diff --git a/core/modules/review/helpers/index.ts b/core/modules/review/helpers/index.ts new file mode 100644 index 000000000..13e147374 --- /dev/null +++ b/core/modules/review/helpers/index.ts @@ -0,0 +1,3 @@ +import createLoadReviewsQuery from './createLoadReviewsQuery' + +export { createLoadReviewsQuery } diff --git a/core/modules/review/index.ts b/core/modules/review/index.ts index f41f26261..cf72bfa12 100644 --- a/core/modules/review/index.ts +++ b/core/modules/review/index.ts @@ -1,8 +1,6 @@ -import { module } from './store' -import { createModule } from '@vue-storefront/core/lib/module' +import { StorefrontModule } from '@vue-storefront/core/lib/modules' +import { reviewStore } from './store' -export const KEY = 'review' -export const Review = createModule({ - key: KEY, - store: { modules: [{ key: KEY, module }] } -}) +export const ReviewModule: StorefrontModule = function ({store}) { + store.registerModule('review', reviewStore) +} diff --git a/core/modules/review/store/actions.ts b/core/modules/review/store/actions.ts index 25e5b439c..5fee4b1b6 100644 --- a/core/modules/review/store/actions.ts +++ b/core/modules/review/store/actions.ts @@ -1,93 +1,32 @@ -import Vue from 'vue' +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' import { ActionTree } from 'vuex' import { quickSearchByQuery } from '@vue-storefront/core/lib/search' -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' -import { adjustMultistoreApiUrl } from '@vue-storefront/core/lib/multistore' import RootState from '@vue-storefront/core/types/RootState' import ReviewState from '../types/ReviewState' import * as types from './mutation-types' import i18n from '@vue-storefront/i18n' -import rootStore from '@vue-storefront/core/store' import Review from '@vue-storefront/core/modules/review/types/Review' -import { ReviewRequest } from '@vue-storefront/core/modules/review/types/ReviewRequest' -import { Logger } from '@vue-storefront/core/lib/logger' -import config from 'config' -import { processURLAddress } from '@vue-storefront/core/helpers' +import { createLoadReviewsQuery } from '@vue-storefront/core/modules/review/helpers' +import { ReviewsService } from '@vue-storefront/core/data-resolver' const actions: ActionTree = { - /** - * Retrieve reviews - * - * @param context - * @param {any} query - * @param {any} start - * @param {any} size - * @param {any} entityType - * @param {any} sort - * @param {any} excludeFields - * @param {any} includeFields - * @returns {Promise & Promise} - */ - list (context, {productId, approved = true, start = 0, size = 50, entityType = 'review', sort = '', excludeFields = null, includeFields = null}) { - let query = new SearchQuery() + async list (context, {productId, approved = true, start = 0, size = 50, entityType = 'review', sort = '', excludeFields = null, includeFields = null}) { + const query = createLoadReviewsQuery({ productId, approved }) - if (productId) { - query = query.applyFilter({key: 'product_id', value: {'eq': productId}}) - } - - if (approved) { - query = query.applyFilter({key: 'review_status', value: {'eq': 1}}) - } - - quickSearchByQuery({ query, start, size, entityType, sort, excludeFields, includeFields }).then((resp) => { - context.commit(types.REVIEW_UPD_REVIEWS, resp) - }).catch(err => { - Logger.error(err, 'review')() - }) + const reviewResponse = await quickSearchByQuery({ query, start, size, entityType, sort, excludeFields, includeFields }) + context.commit(types.REVIEW_UPD_REVIEWS, reviewResponse) }, + async add (context, review: Review) { + EventBus.$emit('notification-progress-start', i18n.t('Adding a review ...')) - /** - * Add new review - * - * @param context - * @param {Review} reviewData - * @returns {Promise} - */ - async add (context, reviewData: Review) { - const review: ReviewRequest = {review: reviewData} - - Vue.prototype.$bus.$emit('notification-progress-start', i18n.t('Adding a review ...')) - - let url = config.reviews.create_endpoint + const isReviewCreated = await ReviewsService.createReview(review) + EventBus.$emit('notification-progress-stop') - if (config.storeViews.multistore) { - url = adjustMultistoreApiUrl(url) + if (isReviewCreated) { + EventBus.$emit('clear-add-review-form') } - try { - await fetch(processURLAddress(url), { - method: 'POST', - headers: { - 'Accept': 'application/json, text/plain, */*', - 'Content-Type': 'application/json' - }, - body: JSON.stringify(review) - }) - Vue.prototype.$bus.$emit('notification-progress-stop') - rootStore.dispatch('notification/spawnNotification', { - type: 'success', - message: i18n.t('You submitted your review for moderation.'), - action1: { label: i18n.t('OK') } - }) - Vue.prototype.$bus.$emit('clear-add-review-form') - } catch (e) { - Vue.prototype.$bus.$emit('notification-progress-stop') - rootStore.dispatch('notification/spawnNotification', { - type: 'error', - message: i18n.t('Something went wrong. Try again in a few seconds.'), - action1: { label: i18n.t('OK') } - }) - }; + return isReviewCreated } } diff --git a/core/modules/review/store/index.ts b/core/modules/review/store/index.ts index b5d0b40f6..70778f8ec 100644 --- a/core/modules/review/store/index.ts +++ b/core/modules/review/store/index.ts @@ -4,7 +4,7 @@ import mutations from './mutations'; import RootState from '@vue-storefront/core/types/RootState'; import ReviewState from '../types/ReviewState'; -export const module: Module = { +export const reviewStore: Module = { namespaced: true, state: { items: [] diff --git a/core/modules/review/test/unit/helpers/createLoadReviewsQuery.spec.ts b/core/modules/review/test/unit/helpers/createLoadReviewsQuery.spec.ts new file mode 100644 index 000000000..c67cc9714 --- /dev/null +++ b/core/modules/review/test/unit/helpers/createLoadReviewsQuery.spec.ts @@ -0,0 +1,28 @@ +import createLoadReviewsQuery from '../../../helpers/createLoadReviewsQuery' + +const SearchQuery = { + applyFilter: jest.fn(() => SearchQuery) +} + +jest.mock('@vue-storefront/core/lib/search/searchQuery', () => () => SearchQuery) + +describe('createLoadReviewsQuery', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('add filter only for productId argument', () => { + createLoadReviewsQuery({ productId: 123, approved: false }) + + expect(SearchQuery.applyFilter).toBeCalledTimes(1) + expect(SearchQuery.applyFilter).toBeCalledWith({key: 'product_id', value: {'eq': 123}}); + }); + + it('add filter for productId and approved arguments', () => { + createLoadReviewsQuery({ productId: 123, approved: true }) + + expect(SearchQuery.applyFilter).toBeCalledTimes(2) + expect(SearchQuery.applyFilter).toBeCalledWith({key: 'product_id', value: {'eq': 123}}); + expect(SearchQuery.applyFilter).toBeCalledWith({key: 'review_status', value: {'eq': 1}}); + }); +}) diff --git a/core/modules/review/test/unit/store/actions.spec.ts b/core/modules/review/test/unit/store/actions.spec.ts new file mode 100644 index 000000000..05085e91a --- /dev/null +++ b/core/modules/review/test/unit/store/actions.spec.ts @@ -0,0 +1,152 @@ +import * as types from '../../../store/mutation-types'; +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 { ReviewsService } from '@vue-storefront/core/data-resolver' + +jest.mock('@vue-storefront/core/helpers', () => ({ + once: (str) => jest.fn(), + processLocalizedURLAddress: jest.fn() +})) +jest.mock('@vue-storefront/i18n', () => ({ t: jest.fn(str => str) })); +jest.mock('@vue-storefront/core/store', () => ({ + state: { + items: [] + } +})) +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(() => ({ + storeCode: 'de' + })) +})); +jest.mock('@vue-storefront/core/lib/search', () => ({ + quickSearchByQuery: jest.fn() +})); +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})) + } +})) +jest.mock('@vue-storefront/core/data-resolver', () => ({ + ReviewsService: { + createReview: jest.fn(() => true) + } +})) + +EventBus.$emit = jest.fn() + +describe('Review actions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('list', () => { + it('create load reviews query', async () => { + const contextMock = { + commit: jest.fn() + }; + const payload = { productId: 1 } + const wrapper = (actions: any) => actions.list(contextMock, payload); + + await wrapper(reviewActions); + + expect(createLoadReviewsQuery).toBeCalledWith({...payload, approved: true}); + }); + + it('make quick search by query with default values', async () => { + const contextMock = { + commit: jest.fn() + }; + const payload = { productId: 1 } + const wrapper = (actions: any) => actions.list(contextMock, payload); + + (createLoadReviewsQuery as jest.Mock).mockImplementationOnce(() => SearchQuery); + + await wrapper(reviewActions); + + expect(quickSearchByQuery).toBeCalledWith({ + query: SearchQuery, + start: 0, + size: 50, + entityType: 'review', + sort: '', + excludeFields: null, + includeFields: null + }); + }); + + it('call review update commit', async () => { + const contextMock = { + commit: jest.fn() + }; + const wrapper = (actions: any) => actions.list(contextMock, { productId: 1 }); + + (quickSearchByQuery as jest.Mock).mockImplementationOnce(() => Promise.resolve(expect.anything())); + + await wrapper(reviewActions); + + expect(contextMock.commit).toBeCalledWith(types.REVIEW_UPD_REVIEWS, expect.anything()); + }); + }); + + describe('add', () => { + it('notify about starting process of adding a review', () => { + const contextMock = { + commit: jest.fn() + }; + const payload = expect.anything() + const wrapper = (actions: any) => actions.add(contextMock, payload); + + wrapper(reviewActions); + + expect(EventBus.$emit).toBeCalledWith('notification-progress-start', expect.anything()) + }); + + it('notify about finished process of adding a review', async () => { + const contextMock = { + commit: jest.fn() + }; + const payload = expect.anything() + const wrapper = (actions: any) => actions.add(contextMock, payload); + + await wrapper(reviewActions); + + expect(EventBus.$emit).toBeCalledWith('notification-progress-stop') + }); + + it('send event to clear review form after success', async () => { + const contextMock = { + commit: jest.fn() + }; + const payload = expect.anything() + const wrapper = (actions: any) => actions.add(contextMock, payload); + + await wrapper(reviewActions); + + expect(EventBus.$emit).toBeCalledTimes(3) + expect(EventBus.$emit).toBeCalledWith('clear-add-review-form') + }); + + it('don\'t send event to clear review form after fail', async () => { + const contextMock = { + commit: jest.fn() + }; + const payload = expect.anything() + const wrapper = (actions: any) => actions.add(contextMock, payload); + + (ReviewsService.createReview as jest.Mock).mockImplementationOnce(jest.fn(() => false)) + + await wrapper(reviewActions); + + expect(EventBus.$emit).toBeCalledTimes(2) + }); + }) +}) diff --git a/core/modules/review/test/unit/store/mutations.spec.ts b/core/modules/review/test/unit/store/mutations.spec.ts new file mode 100644 index 000000000..cd5affac7 --- /dev/null +++ b/core/modules/review/test/unit/store/mutations.spec.ts @@ -0,0 +1,27 @@ +import * as types from '../../../store/mutation-types' +import reviewedMutations from '../../../store/mutations' + +describe('Review mutations', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('REVIEW_UPD_REVIEWS', () => { + it('update review items', () => { + const stateMock = { + items: [] + } + const reviewItem = {foo: '123'} + const expectedState = { + items: [ + reviewItem + ] + } + const wrapper = (mutations: any) => mutations[types.REVIEW_UPD_REVIEWS](stateMock, [ reviewItem ]) + + wrapper(reviewedMutations) + + expect(stateMock).toEqual(expectedState) + }) + }) +}) diff --git a/core/modules/review/types/Review.ts b/core/modules/review/types/Review.ts index ccf551ec2..8786c9eb4 100644 --- a/core/modules/review/types/Review.ts +++ b/core/modules/review/types/Review.ts @@ -1,10 +1,10 @@ export default interface Review { - product_id: number, + product_id: number | string, title: string, detail: string, nickname: string, review_entity: string, review_status: number, - customer_id?: number | null, + customer_id?: number | string | null, [k: string]: any } diff --git a/core/modules/url/helpers/index.ts b/core/modules/url/helpers/index.ts index 29a940f01..c00bc3fad 100644 --- a/core/modules/url/helpers/index.ts +++ b/core/modules/url/helpers/index.ts @@ -4,6 +4,8 @@ import { LocalizedRoute } from '@vue-storefront/core/lib/types' import { localizedDispatcherRoute, localizedRoute, currentStoreView } from '@vue-storefront/core/lib/multistore' import { RouteConfig } from 'vue-router/types/router'; import { RouterManager } from '@vue-storefront/core/lib/router-manager' +import { Category } from 'core/modules/catalog-next/types/Category' +import { Logger } from '@vue-storefront/core/lib/logger' export function parametrizeRouteData (routeData: LocalizedRoute, query: { [id: string]: any } | string, storeCodeInPath: string): LocalizedRoute { const parametrizedRoute = Object.assign({}, routeData) @@ -14,33 +16,36 @@ export function parametrizeRouteData (routeData: LocalizedRoute, query: { [id: s return parametrizedRoute } -function prepareDynamicRoutes (routeData: LocalizedRoute, path: string): RouteConfig[] { +function prepareDynamicRoute (routeData: LocalizedRoute, path: string): RouteConfig { const userRoute = RouterManager.findByName(routeData.name) if (userRoute) { - const currentStoreCode = currentStoreView().storeCode - const dynamicRouteName = (config.defaultStoreCode !== currentStoreCode) ? `urldispatcher-${path}-${currentStoreCode}` : `urldispatcher-${path}` - const dynamicRoute = Object.assign({}, userRoute, routeData, { path: '/' + path, name: dynamicRouteName }) - return [dynamicRoute] + const normalizedPath = `${path.startsWith('/') ? '' : '/'}${path}` + const dynamicRoute = Object.assign({}, userRoute, routeData, { path: normalizedPath, name: `urldispatcher-${normalizedPath}` }) + return dynamicRoute } else { - return [] + Logger.error('Route not found ' + routeData['name'], 'dispatcher')() + return null } } -export function processDynamicRoute (routeData: LocalizedRoute, path: string, addToRoutes: boolean = true): LocalizedRoute[] { - const preparedRoutes = prepareDynamicRoutes(routeData, path) - if (addToRoutes && preparedRoutes) { - RouterManager.addRoutes(preparedRoutes, router) +export function processDynamicRoute (routeData: LocalizedRoute, path: string, addToRoutes: boolean = true): LocalizedRoute { + const preparedRoute = prepareDynamicRoute(routeData, path) + if (addToRoutes && preparedRoute) { + router.addRoutes([preparedRoute], true) } - return preparedRoutes + return preparedRoute } -export function processMultipleDynamicRoutes (dispatcherMap: {}, addToRoutes: boolean = true): LocalizedRoute[] { +export function preProcessDynamicRoutes (dispatcherMap: {}, addToRoutes: boolean = true): LocalizedRoute[] { const preparedRoutes = [] for (const [url, routeData] of Object.entries(dispatcherMap)) { - preparedRoutes.push(...prepareDynamicRoutes(routeData, url)) + const preparedRoute = prepareDynamicRoute(routeData, url) + if (preparedRoute) { + preparedRoutes.push(preparedRoute) + } } if (addToRoutes) { - RouterManager.addRoutes(preparedRoutes, router) + router.addRoutes(preparedRoutes, true) } return preparedRoutes } @@ -51,7 +56,7 @@ export function findRouteByPath (path: string): RouteConfig { export function normalizeUrlPath (url: string): string { if (url && url.length > 0) { - if (url[0] === '/') url = url.slice(1) + if (url.length > 0 && !url.startsWith('/')) url = `/${url}` if (url.endsWith('/')) url = url.slice(0, -1) const queryPos = url.indexOf('?') if (queryPos > 0) url = url.slice(0, queryPos) @@ -59,7 +64,7 @@ export function normalizeUrlPath (url: string): string { return url } -export function formatCategoryLink (category: { url_path: string, slug: string }, storeCode: string = currentStoreView().storeCode): string { +export function formatCategoryLink (category: Category, storeCode: string = currentStoreView().storeCode): string { storeCode ? storeCode += '/' : storeCode = ''; if (currentStoreView().appendStoreCode === false) { @@ -79,13 +84,14 @@ export function formatProductLink ( url_path?: string, type_id: string, slug: string, - configurable_children: [] + options?: [], + configurable_children?: [] }, storeCode ): string | LocalizedRoute { if (config.seo.useUrlDispatcher && product.url_path) { let routeData: LocalizedRoute; - if (product.configurable_children && product.configurable_children.length > 0) { + if ((product.options && product.options.length > 0) || (product.configurable_children && product.configurable_children.length > 0)) { routeData = { path: product.url_path, params: { childSku: product.sku } @@ -106,3 +112,14 @@ export function formatProductLink ( return localizedRoute(routeData, storeCode) } } + +export const getFallbackRouteData = ({ mappedFallback, url }) => { + if (Array.isArray(mappedFallback)) { + return mappedFallback + .reverse() + .filter(f => f.params && f.params.slug) + .find(f => url.includes(f.params.slug)) + } + + return mappedFallback +} diff --git a/core/modules/url/index.ts b/core/modules/url/index.ts index c28473c38..8f4f45386 100644 --- a/core/modules/url/index.ts +++ b/core/modules/url/index.ts @@ -1,15 +1,11 @@ -import { module } from './store' -import { createModule } from '@vue-storefront/core/lib/module' +import { urlStore } from './store' +import { StorefrontModule } from '@vue-storefront/core/lib/modules' +import { beforeEachGuard } from './router/beforeEach' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' -import { VueStorefrontModule, VueStorefrontModuleConfig } from '@vue-storefront/core/lib/module' -import { initCacheStorage } from '@vue-storefront/core/helpers/initCacheStorage' -import { beforeEach } from './router/beforeEach' +export const cacheStorage = StorageManager.init('url') -export const KEY = 'url' -export const cacheStorage = initCacheStorage(KEY) - -export const Url = createModule({ - key: KEY, - store: { modules: [{ key: KEY, module }] }, - router: { beforeEach } -}) +export const UrlModule: StorefrontModule = function ({store, router}) { + store.registerModule('url', urlStore) + router.beforeEach(beforeEachGuard) +} diff --git a/core/modules/url/router/beforeEach.ts b/core/modules/url/router/beforeEach.ts index 72ad0d74d..2dc8e8919 100644 --- a/core/modules/url/router/beforeEach.ts +++ b/core/modules/url/router/beforeEach.ts @@ -5,10 +5,8 @@ import { Route } from 'vue-router' import store from '@vue-storefront/core/store' import { Logger } from '@vue-storefront/core/lib/logger' import { processDynamicRoute, normalizeUrlPath } from '../helpers' -import { isServer } from '@vue-storefront/core/helpers' -import { currentStoreView, localizedRoute } from '@vue-storefront/core/lib/multistore' +import { currentStoreView } from '@vue-storefront/core/lib/multistore' import { LocalizedRoute } from '@vue-storefront/core/lib/types' -import Vue from 'vue' import { RouterManager } from '@vue-storefront/core/lib/router-manager' import { routerHelper } from '@vue-storefront/core/helpers' @@ -17,7 +15,7 @@ export const UrlDispatchMapper = async (to) => { return Object.assign({}, to, routeData) } -export async function beforeEach (to: Route, from: Route, next) { +export async function beforeEachGuard (to: Route, from: Route, next) { if (RouterManager.isRouteProcessing()) { await RouterManager.getRouteLockPromise() next() @@ -28,39 +26,36 @@ 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)) { + const storeCode = currentStoreView().storeCode + try { + const routeData = await UrlDispatchMapper(to) if (routeData) { - let dynamicRoutes: LocalizedRoute[] = processDynamicRoute(routeData, path, !isPreviouslyDispatchedDynamicRoute) - if (dynamicRoutes && dynamicRoutes.length > 0) { + let dynamicRoute: LocalizedRoute = processDynamicRoute(routeData, path, !isPreviouslyDispatchedDynamicRoute) + if (dynamicRoute) { next({ - ...dynamicRoutes[0], - replace: routerHelper.popStateDetected || dynamicRoutes[0].fullPath === from.fullPath + ...dynamicRoute, + replace: routerHelper.popStateDetected || dynamicRoute.fullPath === from.fullPath }) } 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/url/store/actions.ts b/core/modules/url/store/actions.ts index 86f8f7ca5..8cac1ff8a 100644 --- a/core/modules/url/store/actions.ts +++ b/core/modules/url/store/actions.ts @@ -6,8 +6,8 @@ import { cacheStorage } from '../' import queryString from 'query-string' import config from 'config' import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' -import { processMultipleDynamicRoutes, normalizeUrlPath, parametrizeRouteData } from '../helpers' -import { removeStoreCodeFromRoute } from '@vue-storefront/core/lib/multistore' +import { preProcessDynamicRoutes, normalizeUrlPath, parametrizeRouteData, getFallbackRouteData } from '../helpers' +import { removeStoreCodeFromRoute, currentStoreView, localizedDispatcherRouteName } from '@vue-storefront/core/lib/multistore' import storeCodeFromRoute from '@vue-storefront/core/lib/storeCodeFromRoute' // it's a good practice for all actions to return Promises with effect of their execution @@ -31,20 +31,19 @@ export const actions: ActionTree = { * Register dynamic vue-router routes */ async registerDynamicRoutes ({ state, dispatch }) { - if (state.dispatcherMap) { - processMultipleDynamicRoutes(state.dispatcherMap) // check if we're to add routes to vue router - const registrationQueue = [] - for (const [url, routeData] of Object.entries(state.dispatcherMap)) { - registrationQueue.push(dispatch('registerMapping', { url, routeData })) - } - Promise.all(registrationQueue) - } + if (!state.dispatcherMap) return + + preProcessDynamicRoutes(state.dispatcherMap) + const registrationRoutePromises = Object.keys(state.dispatcherMap).map(url => { + const routeData = state.dispatcherMap[url] + return dispatch('registerMapping', { url, routeData }) + }) + await Promise.all(registrationRoutePromises) }, mapUrl ({ state, dispatch }, { url, query }: { url: string, query: string}) { const parsedQuery = typeof query === 'string' ? queryString.parse(query) : query const storeCodeInPath = storeCodeFromRoute(url) url = normalizeUrlPath(url) - return new Promise((resolve, reject) => { if (state.dispatcherMap[url]) { return resolve(parametrizeRouteData(state.dispatcherMap[url], query, storeCodeInPath)) @@ -53,7 +52,8 @@ export const actions: ActionTree = { if (routeData !== null) { return resolve(parametrizeRouteData(routeData, query, storeCodeInPath)) } else { - dispatch('mappingFallback', { url, params: parsedQuery }).then((routeData) => { + dispatch('mappingFallback', { url, params: parsedQuery }).then(mappedFallback => { + const routeData = getFallbackRouteData({ mappedFallback, url }) dispatch('registerMapping', { url, routeData }) // register mapping for further usage resolve(parametrizeRouteData(routeData, query, storeCodeInPath)) }).catch(reject) @@ -67,14 +67,15 @@ export const actions: ActionTree = { * 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() const productQuery = new SearchQuery() - url = (removeStoreCodeFromRoute(url) as string) + 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: product.type_id + '-product', + name: localizedDispatcherRouteName(product.type_id + '-product', storeCode, appendStoreCode), params: { slug: product.slug, parentSku: product.sku, @@ -85,7 +86,7 @@ export const actions: ActionTree = { const category = await dispatch('category/single', { key: 'url_path', value: url }, { root: true }) if (category !== null) { return { - name: 'category', + name: localizedDispatcherRouteName('category', storeCode, appendStoreCode), params: { slug: category.slug } diff --git a/core/modules/url/store/index.ts b/core/modules/url/store/index.ts index e928a5494..336bade11 100644 --- a/core/modules/url/store/index.ts +++ b/core/modules/url/store/index.ts @@ -4,7 +4,7 @@ import { mutations } from './mutations' import { actions } from './actions' import { state } from './state' -export const module: Module = { +export const urlStore: Module = { namespaced: true, mutations, actions, diff --git a/core/modules/url/store/mutations.ts b/core/modules/url/store/mutations.ts index 2150bab1a..ed2a7a802 100644 --- a/core/modules/url/store/mutations.ts +++ b/core/modules/url/store/mutations.ts @@ -3,6 +3,6 @@ import * as types from './mutation-types' export const mutations: MutationTree = { [types.REGISTER_MAPPING] (state, payload) { - state.dispatcherMap[payload.url] = payload.routeData + state.dispatcherMap = Object.assign({}, state.dispatcherMap, { [payload.url]: payload.routeData }) } } diff --git a/core/modules/url/test/unit/helpers/data.ts b/core/modules/url/test/unit/helpers/data.ts new file mode 100644 index 000000000..410b0645a --- /dev/null +++ b/core/modules/url/test/unit/helpers/data.ts @@ -0,0 +1,11 @@ +let product = { + sku: 'MSH09', + slug: 'troy-yoga-short-994', + url_path: 'men/bottoms-men/shorts-men/shorts-19/troy-yoga-short-994.html', + type_id: 'configurable', + parentSku: 'MSH09' +} + +export { + product +} diff --git a/core/modules/url/test/unit/helpers/formatCategoryLink.spec.ts b/core/modules/url/test/unit/helpers/formatCategoryLink.spec.ts new file mode 100644 index 000000000..487e70623 --- /dev/null +++ b/core/modules/url/test/unit/helpers/formatCategoryLink.spec.ts @@ -0,0 +1,148 @@ +import { formatCategoryLink } from '@vue-storefront/core/modules/url/helpers'; +import { Category } from '@vue-storefront/core/modules/catalog-next/types/Category'; +import { currentStoreView } from '@vue-storefront/core/lib/multistore'; +import config from 'config'; + +jest.mock('@vue-storefront/core/app', () => jest.fn()); +jest.mock('@vue-storefront/core/lib/router-manager', () => jest.fn()); +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(), + localizedDispatcherRoute: jest.fn(), + localizedRoute: jest.fn() +})); +jest.mock('@vue-storefront/core/helpers', () => ({ + once: (str) => jest.fn() +})) + +describe('formatCategoryLink method', () => { + let category: Category; + + beforeEach(() => { + jest.clearAllMocks(); + jest.mock('config', () => ({})); + (currentStoreView as jest.Mock).mockImplementation(() => ({storeCode: ''})); + category = { + path: '1/2', + is_active: true, + level: 1, + product_count: 1181, + children_count: '38', + parent_id: 1, + name: 'All', + id: 2, + url_key: 'all-2', + children_data: [], + url_path: 'all-2/women/women-20', + slug: 'all-2' + }; + }); + + describe('with active urlDispatcher', () => { + beforeEach(() => { + config.seo = { + useUrlDispatcher: true + }; + }); + + it('should return formatted category url_path', () => { + const result = formatCategoryLink(category); + expect(result).toEqual('/all-2/women/women-20'); + }); + + it('should return formatted category url_path when storeCode passed as null', () => { + const result = formatCategoryLink(category, null); + expect(result).toEqual('/all-2/women/women-20'); + }); + + it('should return formatted category url_path when storeCode passed as \'de\'', () => { + const result = formatCategoryLink(category, 'de'); + expect(result).toEqual('/de/all-2/women/women-20'); + }); + + it('should return formatted category url_path when storeCode passed as \'\'', () => { + const result = formatCategoryLink(category, ''); + expect(result).toEqual('/all-2/women/women-20'); + }); + + it('should return homepage path when category passed as \'null\'', () => { + const result = formatCategoryLink(null); + expect(result).toEqual('/'); + }); + + describe('with default storeCode set to \'de\'', () => { + beforeEach(() => { + (currentStoreView as jest.Mock).mockImplementation(() => ({storeCode: 'de'})); + }); + + it('should return formatted category url_path', () => { + const result = formatCategoryLink(category); + expect(result).toEqual('/de/all-2/women/women-20'); + }); + + it('should return homepage path when category passed as \'null\'', () => { + const result = formatCategoryLink(null); + expect(result).toEqual('/de/'); + }); + }); + }); + + describe('without urlDispatcher', () => { + beforeEach(() => { + config.seo = { + useUrlDispatcher: false + }; + }); + + it('should return old path with c and category slug', () => { + const result = formatCategoryLink(category); + expect(result).toEqual('/c/all-2'); + }); + + it('should return old path with c and category slug when storeCode passed as null', () => { + const result = formatCategoryLink(category, null); + expect(result).toEqual('/c/all-2'); + }); + + it('should return old path with c and category slug when storeCode passed as \'de\'', () => { + const result = formatCategoryLink(category, 'de'); + expect(result).toEqual('/de/c/all-2'); + }); + + it('should return old path with c and category slug when storeCode passed as \'\'', () => { + const result = formatCategoryLink(category, ''); + expect(result).toEqual('/c/all-2'); + }); + + describe('with default storeCode set to \'de\'', () => { + beforeEach(() => { + (currentStoreView as jest.Mock).mockImplementation(() => ({storeCode: 'de'})); + }); + + it('should return formatted category url_path', () => { + const result = formatCategoryLink(category); + expect(result).toEqual('/de/c/all-2'); + }); + + it('should return homepage path when category passed as \'null\'', () => { + const result = formatCategoryLink(null); + expect(result).toEqual('/de/'); + }); + }) + + describe('with default storeCode set to \'de\' and appendStoreCode is false', () => { + beforeEach(() => { + (currentStoreView as jest.Mock).mockImplementation(() => ({storeCode: 'de', appendStoreCode: false})); + }); + + it('should return formatted category url_path', () => { + const result = formatCategoryLink(category); + expect(result).toEqual('/c/all-2'); + }); + + it('should return homepage path when category passed as \'null\'', () => { + const result = formatCategoryLink(null); + expect(result).toEqual('/'); + }); + }) + }); +}); diff --git a/core/modules/url/test/unit/helpers/formatProductLink.spec.ts b/core/modules/url/test/unit/helpers/formatProductLink.spec.ts new file mode 100644 index 000000000..1276cd5db --- /dev/null +++ b/core/modules/url/test/unit/helpers/formatProductLink.spec.ts @@ -0,0 +1,85 @@ + +import { formatProductLink } from '@vue-storefront/core/modules/url/helpers'; +import { LocalizedRoute } from '@vue-storefront/core/lib/types' +import * as data from './data' +import config from 'config'; +import { currentStoreView } from '@vue-storefront/core/lib/multistore'; + +jest.mock('config', () => ({})); +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(), + localizedDispatcherRoute: jest.fn(() => { + return '/men/bottoms-men/shorts-men/shorts-19/troy-yoga-short-994.html?childSku=MSH09' + }), + localizedRoute: jest.fn(() => { + return '/men/bottoms-men/shorts-men/shorts-19/troy-yoga-short-994.html?childSku=MSH09' + }) +})); +jest.mock('@vue-storefront/core/helpers', () => ({ + once: jest.fn() +})) +jest.mock('@vue-storefront/core/helpers/router', () => ({ + createRouter: jest.fn(), + createRouterProxy: jest.fn() +})) +jest.mock('@vue-storefront/core/lib/router-manager', () => ({ + RouterManager: { + findByName: jest.fn() + } +})); +jest.mock('@vue-storefront/core/app', () => ({ + createApp: jest.fn(), + router: { + addRoutes: jest.fn() + } +})) +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + log: jest.fn(() => () => { + }), + debug: jest.fn(() => () => { + }), + warn: jest.fn(() => () => { + }), + error: jest.fn(() => () => { + }), + info: jest.fn(() => () => { + }) + } +})); + +let expectedProductLink: LocalizedRoute | string; + +describe('formatProductLink helper with useUrlDispatcher set to true', () => { + beforeEach(() => { + jest.clearAllMocks(); + (currentStoreView as jest.Mock).mockImplementationOnce(() => ({ storeCode: '' })); + config.seo = { + useUrlDispatcher: true + }; + }) + + it('should return localized dispatcher route', () => { + expectedProductLink = '/men/bottoms-men/shorts-men/shorts-19/troy-yoga-short-994.html?childSku=MSH09' + const result = formatProductLink(data.product, ''); + + expect(result).toEqual(expectedProductLink); + }) +}) + +describe('formatProductLink helper with userUrlDispatcher set to false', () => { + beforeEach(() => { + jest.clearAllMocks(); + (currentStoreView as jest.Mock).mockImplementationOnce(() => ({ storeCode: '' })); + config.seo = { + useUrlDispatcher: false + }; + }) + + it('should return localized route', () => { + expectedProductLink = '/men/bottoms-men/shorts-men/shorts-19/troy-yoga-short-994.html?childSku=MSH09' + const result = formatProductLink(data.product, ''); + + expect(result).toEqual(expectedProductLink); + }) +}) diff --git a/core/modules/url/test/unit/helpers/normalizeUrlPath.spec.ts b/core/modules/url/test/unit/helpers/normalizeUrlPath.spec.ts new file mode 100644 index 000000000..25dafeaf6 --- /dev/null +++ b/core/modules/url/test/unit/helpers/normalizeUrlPath.spec.ts @@ -0,0 +1,37 @@ +import { normalizeUrlPath } from '@vue-storefront/core/modules/url/helpers'; + +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(() => ({ + storeCode: '2', + localizedRoute: jest.fn() + })) +})); +jest.mock('@vue-storefront/core/helpers', () => ({ + once: jest.fn() +})) +jest.mock('@vue-storefront/core/helpers/router', () => ({ + createRouter: jest.fn(), + createRouterProxy: jest.fn() +})) +jest.mock('@vue-storefront/core/app', () => ({ + createApp: jest.fn(), + router: { + addRoutes: jest.fn() + } +})) + +let expectedUrl: string; + +describe('normalizeUrlPath helper', () => { + beforeEach(() => { + jest.clearAllMocks() + + expectedUrl = '/gear/gear-3' + }) + + it('should return normalized url path', () => { + const result = normalizeUrlPath('/gear/gear-3') + + expect(result).toEqual(expectedUrl) + }) +}) diff --git a/core/modules/url/test/unit/helpers/parametrizeRouteData.spec.ts b/core/modules/url/test/unit/helpers/parametrizeRouteData.spec.ts new file mode 100644 index 000000000..c10433d5e --- /dev/null +++ b/core/modules/url/test/unit/helpers/parametrizeRouteData.spec.ts @@ -0,0 +1,44 @@ +import { parametrizeRouteData } from '@vue-storefront/core/modules/url/helpers'; +import { LocalizedRoute } from '@vue-storefront/core/lib/types' + +jest.mock('@vue-storefront/core/app', () => jest.fn()); +jest.mock('@vue-storefront/core/lib/router-manager', () => jest.fn()); +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(), + localizedDispatcherRoute: jest.fn(), + localizedRoute: jest.fn() +})); +jest.mock('@vue-storefront/core/helpers', () => ({ + once: (str) => jest.fn() +})) + +let expectedParametrizedRoute; +let routeData: LocalizedRoute; +let query; + +describe('parametrizeRouteData helper', () => { + beforeEach(() => { + jest.clearAllMocks(); + + query = { + slug: 'pants-18' + } + routeData = { + name: 'category', + params: { + slug: 'pants-18' + } + } + expectedParametrizedRoute = { + name: 'category', + params: { + slug: 'pants-18' + } + } + }) + + it('should return parametrizedRoute', () => { + const result = parametrizeRouteData(routeData, query, '') + expect(result).toEqual(expectedParametrizedRoute) + }) +}) diff --git a/core/modules/url/test/unit/helpers/preProcessDynamicRoutes.spec.ts b/core/modules/url/test/unit/helpers/preProcessDynamicRoutes.spec.ts new file mode 100644 index 000000000..1008a5261 --- /dev/null +++ b/core/modules/url/test/unit/helpers/preProcessDynamicRoutes.spec.ts @@ -0,0 +1,93 @@ +import { preProcessDynamicRoutes } from '@vue-storefront/core/modules/url/helpers'; +import { LocalizedRoute } from '@vue-storefront/core/lib/types' +import { RouterManager } from '../../../../../lib/router-manager'; + +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(() => ({ + storeCode: '2', + localizedRoute: jest.fn() + })) +})); +jest.mock('@vue-storefront/core/helpers', () => ({ + once: jest.fn() +})) +jest.mock('@vue-storefront/core/helpers/router', () => ({ + createRouter: jest.fn(), + createRouterProxy: jest.fn() +})) +jest.mock('@vue-storefront/core/lib/router-manager', () => ({ + RouterManager: { + findByName: jest.fn() + } +})); +jest.mock('@vue-storefront/core/app', () => ({ + createApp: jest.fn(), + router: { + addRoutes: jest.fn() + } +})) +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + log: jest.fn(() => () => { + }), + debug: jest.fn(() => () => { + }), + warn: jest.fn(() => () => { + }), + error: jest.fn(() => () => { + }), + info: jest.fn(() => () => { + }) + } +})); + +let dispatcherMap: Record; +let expectedPreparedRoutes: LocalizedRoute[]; + +describe('preProcessDynamicRoutes helper', () => { + beforeEach(() => { + jest.clearAllMocks(); + + dispatcherMap = { + '/all-2': { + name: 'category', + params: { + slug: 'all-2' + } + } + } + expectedPreparedRoutes = [ + { + name: 'urldispatcher-/all-2', + params: { + slug: 'all-2' + }, + path: '/all-2' + } + ] + }) + + it('should return array with preparedRoutes', () => { + (RouterManager.findByName as jest.Mock).mockImplementationOnce(() => ({ + name: 'category', + path: '/all-2' + })); + + const result = preProcessDynamicRoutes(dispatcherMap, true) + + expect(result).toEqual(expectedPreparedRoutes) + }) + + it('should return blank array with null if it does not find user route', () => { + (RouterManager.findByName as jest.Mock).mockImplementationOnce(() => ({ + name: 'category', path: '/all-2' + })); + dispatcherMap = { + } + expectedPreparedRoutes = [] + + const result = preProcessDynamicRoutes(dispatcherMap, true) + + expect(result).toEqual(expectedPreparedRoutes) + }) +}) diff --git a/core/modules/url/test/unit/helpers/processDynamicRoute.spec.ts b/core/modules/url/test/unit/helpers/processDynamicRoute.spec.ts new file mode 100644 index 000000000..beaad836d --- /dev/null +++ b/core/modules/url/test/unit/helpers/processDynamicRoute.spec.ts @@ -0,0 +1,91 @@ +import { processDynamicRoute } from '@vue-storefront/core/modules/url/helpers'; +import { LocalizedRoute } from '@vue-storefront/core/lib/types' +import { RouterManager } from '../../../../../lib/router-manager'; + +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(() => ({ + storeCode: '2', + localizedRoute: jest.fn() + })) +})); +jest.mock('@vue-storefront/core/helpers', () => ({ + once: jest.fn() +})) +jest.mock('@vue-storefront/core/helpers/router', () => ({ + createRouter: jest.fn(), + createRouterProxy: jest.fn() +})) +jest.mock('@vue-storefront/core/lib/router-manager', () => ({ + RouterManager: { + findByName: jest.fn() + } +})); +jest.mock('@vue-storefront/core/app', () => ({ + createApp: jest.fn(), + router: { + addRoutes: jest.fn() + } +})) +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(() => ({ + storeCode: '2', + localizedRoute: jest.fn() + })) +})); +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + log: jest.fn(() => () => { + }), + debug: jest.fn(() => () => { + }), + warn: jest.fn(() => () => { + }), + error: jest.fn(() => () => { + }), + info: jest.fn(() => () => { + }) + } +})); + +let expectedDynamicRoute; +let routeData: LocalizedRoute; + +describe('parametrizeRouteData helper', () => { + beforeEach(() => { + jest.clearAllMocks(); + + routeData = { + name: 'category', + params: { + slug: 'pants-18' + } + } + expectedDynamicRoute = { + name: 'urldispatcher-/men/bottoms-men/pants-men/pants-18', + params: { + slug: 'pants-18' + }, + path: '/men/bottoms-men/pants-men/pants-18' + } + }) + + it('should return parametrizedRoute from prepareDynamicRoute', () => { + (RouterManager.findByName as jest.Mock).mockImplementationOnce(() => ({ + name: 'category', + path: '/c/:slug' + })); + + const result = processDynamicRoute(routeData, '/men/bottoms-men/pants-men/pants-18', false) + + expect(result).toEqual(expectedDynamicRoute) + }) + + it('should return null from prepareDynamicRoute if it does not find route', () => { + routeData.name = 'test-test-test'; + routeData.params.slug = 'test-test-test'; + + const result = processDynamicRoute(routeData, '', false) + + expect(result).toEqual(null) + }) +}) diff --git a/core/modules/url/test/unit/store/actions.spec.ts b/core/modules/url/test/unit/store/actions.spec.ts new file mode 100644 index 000000000..454b417fd --- /dev/null +++ b/core/modules/url/test/unit/store/actions.spec.ts @@ -0,0 +1,243 @@ +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 { normalizeUrlPath, parametrizeRouteData } from '../../../helpers'; + +const SearchQuery = { + applyFilter: 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: { + getItem: jest.fn(), + setItem: jest.fn(), + clear: jest.fn() + } +})); +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ + StorageManager: { + init: jest.fn(), + get: jest.fn(() => cacheStorage), + getItem: jest.fn(), + initCacheStorage: jest.fn() + } +})); +jest.mock('@vue-storefront/core/lib/multistore', () => ({ + currentStoreView: jest.fn(() => ({ + storeCode: '', + localizedRoute: jest.fn(), + appendStoreCode: '' + })), + localizedDispatcherRouteName: jest.fn(), + removeStoreCodeFromRoute: jest.fn(() => '/men/bottoms-men/shorts-men/shorts-19/troy-yoga-short-994.html') +})); +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/modules/url/helpers', () => ({ + preProcessDynamicRoutes: jest.fn(), + parametrizeRouteData: jest.fn(), + removeStoreCodeFromRoute: jest.fn(), + normalizeUrlPath: jest.fn() +})); +jest.mock('@vue-storefront/core/lib/storeCodeFromRoute', () => + jest.fn(() => '') +); +jest.mock('config', () => ({})); +jest.mock('@vue-storefront/core/app', () => ({ + router: { + addRoutes: jest.fn() + } +})); + +let url: string; +let routeData: any; + +describe('Url actions', () => { + beforeEach(() => { + jest.clearAllMocks(); + + url = 'https://www.example.com'; + routeData = 'routeData'; + }); + + describe('registerMapping action', () => { + it('should call register mapping mutation', async () => { + const contextMock = { + commit: jest.fn() + }; + const result = await (urlActions as any).registerMapping(contextMock, { + url, + routeData + }); + + expect(contextMock.commit).toHaveBeenCalledWith(types.REGISTER_MAPPING, { + url, + routeData + }); + expect(result).toEqual(routeData); + }); + }); + + describe('registerDynamicRoutes action', () => { + it('should NOT call registerMapping action if dispatcherMap state is empty', async () => { + const contextMock = { + state: { + dispatcherMap: {} + }, + dispatch: jest.fn() + }; + const wrapper = (actions: any) => + actions.registerDynamicRoutes(contextMock); + + await wrapper(urlActions); + + expect(contextMock.dispatch).not.toBeCalledWith('registerMapping', { + url, + routeData + }); + }); + it('should call registerMapping action if dispatchetMap is not empty', async () => { + const contextMock = { + state: { + dispatcherMap: { + url: 'https://www.example.com' + } + }, + dispatch: jest.fn() + }; + routeData = contextMock.state.dispatcherMap.url; + url = 'url'; + + const wrapper = (actions: any) => + actions.registerDynamicRoutes(contextMock); + + await wrapper(urlActions); + + expect(contextMock.dispatch).toBeCalledWith('registerMapping', { + url, + routeData + }); + }); + }); + + describe('mapUrl action', () => { + beforeEach(() => { + (currentStoreView as jest.Mock).mockImplementation(() => ({ + storeCode: '' + })); + }); + + it('should return resolved promise with parametrizedRoute', () => { + url = '/men/bottoms-men/shorts-men/shorts-19/troy-yoga-short-994.html'; + + (normalizeUrlPath as jest.Mock).mockImplementationOnce(() => url); + (parametrizeRouteData as jest.Mock).mockImplementationOnce(() => ({ + name: 'configurable-product', + params: { + slug: 'troy-yoga-short-994', + parentSku: 'MSH09', + childSku: 'MSH09-32-Black' + } + })); + const contextMock = { + state: { + dispatcherMap: { + '/men/bottoms-men/shorts-men/shorts-19/troy-yoga-short-994.html': { + name: 'configurable-product', + params: { + slug: 'troy-yoga-short-994', + parentSku: 'MSH09', + childSku: 'MSH09-32-Black' + } + } + } + } + }; + const query = { childSku: 'MSH09-32-Black' }; + const expectedResult = new Promise(resolve => + resolve({ + name: 'configurable-product', + params: { + slug: 'troy-yoga-short-994', + parentSku: 'MSH09', + childSku: 'MSH09-32-Black' + } + }) + ); + const result = (urlActions as any).mapUrl(contextMock, { url, query }); + + expect(result).toEqual(expectedResult); + }); + }); + + describe('mappingFallBack action', () => { + beforeEach(() => { + (currentStoreView as jest.Mock).mockImplementation(() => ({ + storeCode: '', + appendStoreCode: '' + })); + }); + + 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); + + const contextMock = { + dispatch: jest.fn() + }; + const params = { + slug: 'slug', + sku: 'parentsku2', + childSku: 'childSku' + }; + + contextMock.dispatch.mockImplementation(() => Promise.resolve({ items: [ { name: 'name1', qty: 2, slug: 'slug1', sku: 'parentsku2' } ] })) + + const result = await (urlActions as any).mappingFallback(contextMock, { url, params }); + + expect(result).toEqual({ + name: '/men/bottoms-men/shorts-men/shorts-19/troy-yoga-short-994.html', + params: { + slug: 'slug1', + parentSku: 'parentsku2', + childSku: 'childSku' + } + }); + }); + + 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); + + const contextMock = { + dispatch: jest.fn() + }; + const params = { + slug: 'shorts-19' + }; + + contextMock.dispatch.mockImplementation(() => Promise.resolve({slug: 'shorts-19'})) + + const result = await (urlActions as any).mappingFallback(contextMock, { url, params }); + + expect(result).toEqual({ + name: '/men/bottoms-men/shorts-men/shorts-19', + params: { + slug: 'shorts-19' + } + }); + }) + }); +}); diff --git a/core/modules/url/test/unit/store/mutations.spec.ts b/core/modules/url/test/unit/store/mutations.spec.ts new file mode 100644 index 000000000..4f890d3a3 --- /dev/null +++ b/core/modules/url/test/unit/store/mutations.spec.ts @@ -0,0 +1,30 @@ +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/components/AccountButton.ts b/core/modules/user/components/AccountButton.ts index 366130aa2..282dfd856 100644 --- a/core/modules/user/components/AccountButton.ts +++ b/core/modules/user/components/AccountButton.ts @@ -1,3 +1,4 @@ +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' export const AccountButton = { name: 'AccountButton', @@ -15,11 +16,11 @@ export const AccountButton = { this.$router.push(this.localizedRoute('/my-account')) } else { this.$store.commit('ui/setAuthElem', 'login') - this.$bus.$emit('modal-show', 'modal-signup') + EventBus.$emit('modal-show', 'modal-signup') } }, logout () { - this.$bus.$emit('user-before-logout') + EventBus.$emit('user-before-logout') this.$router.push(this.localizedRoute('/')) } } diff --git a/core/modules/user/components/UserAccount.ts b/core/modules/user/components/UserAccount.ts index f4cea0fb2..b62cad631 100644 --- a/core/modules/user/components/UserAccount.ts +++ b/core/modules/user/components/UserAccount.ts @@ -170,7 +170,7 @@ export const UserAccount = { }, getUserCompany () { let user = this.$store.state.user.current - if (user.hasOwnProperty('default_billing')) { + if (user && user.hasOwnProperty('default_billing')) { let index for (let i = 0; i < this.currentUser.addresses.length; i++) { if (toString(user.addresses[i].id) === toString(user.default_billing)) { diff --git a/core/modules/user/components/UserShippingDetails.ts b/core/modules/user/components/UserShippingDetails.ts index 020c9fd05..d9aacfc80 100644 --- a/core/modules/user/components/UserShippingDetails.ts +++ b/core/modules/user/components/UserShippingDetails.ts @@ -81,37 +81,30 @@ export const UserShippingDetails = { let updatedShippingDetails if (!this.objectsEqual(this.shippingDetails, this.getShippingDetails())) { updatedShippingDetails = JSON.parse(JSON.stringify(this.$store.state.user.current)) + let updatedShippingDetailsAddress = { + firstname: this.shippingDetails.firstName, + lastname: this.shippingDetails.lastName, + street: [this.shippingDetails.street, this.shippingDetails.house], + city: this.shippingDetails.city, + ...(this.shippingDetails.region ? { region: { region: this.shippingDetails.region } } : {}), + country_id: this.shippingDetails.country, + postcode: this.shippingDetails.postcode, + ...(this.shippingDetails.phone ? { telephone: this.shippingDetails.phone } : {}) + } if (this.currentUser.hasOwnProperty('default_shipping')) { - let index - for (let i = 0; i < this.currentUser.addresses.length; i++) { - if (toString(this.currentUser.addresses[i].id) === toString(this.currentUser.default_shipping)) { - index = i - } - } - if (index >= 0) { - updatedShippingDetails.addresses[index].firstname = this.shippingDetails.firstName - updatedShippingDetails.addresses[index].lastname = this.shippingDetails.lastName - updatedShippingDetails.addresses[index].street = [this.shippingDetails.street, this.shippingDetails.house] - updatedShippingDetails.addresses[index].city = this.shippingDetails.city - updatedShippingDetails.addresses[index].region = { - region: this.shippingDetails.region ? this.shippingDetails.region : null - } - updatedShippingDetails.addresses[index].country_id = this.shippingDetails.country - updatedShippingDetails.addresses[index].postcode = this.shippingDetails.postcode - updatedShippingDetails.addresses[index].telephone = this.shippingDetails.phone ? this.shippingDetails.phone : '' - } else { + if (this.currentUser.addresses.length === 0) { updatedShippingDetails = null + } else { + updatedShippingDetails.addresses = updatedShippingDetails.addresses.map((address) => + toString(address.id) === toString(this.currentUser.default_shipping) + ? {...address, ...updatedShippingDetailsAddress} // update default address if already exist + : address + ) } } else { + // create default address updatedShippingDetails.addresses.push({ - firstname: this.shippingDetails.firstName, - lastname: this.shippingDetails.lastName, - street: [this.shippingDetails.street, this.shippingDetails.house], - city: this.shippingDetails.city, - ...(this.shippingDetails.region ? { region: { region: this.shippingDetails.region } } : {}), - country_id: this.shippingDetails.country, - postcode: this.shippingDetails.postcode, - ...(this.shippingDetails.phone ? { telephone: this.shippingDetails.phone } : {}), + ...updatedShippingDetailsAddress, default_shipping: true }) } @@ -146,55 +139,46 @@ export const UserShippingDetails = { this.shippingDetails = this.getShippingDetails() } }, + readShippingDetailsFromCurrentUser (shippingDetails) { + for (let address of this.currentUser.addresses) { + if (toString(address.id) === toString(this.currentUser.default_shipping)) { + return { + firstName: address.firstname, + lastName: address.lastname, + street: address.street[0], + house: address.street[1], + city: address.city, + postcode: address.postcode, + region: address.region.region ? address.region.region : '', + country: address.country_id, + phone: address.hasOwnProperty('telephone') ? address.telephone : '' + } + } + } + return shippingDetails + }, getShippingDetails () { this.currentUser = Object.assign({}, this.$store.state.user.current) + let shippingDetails = { + firstName: '', + lastName: '', + street: '', + house: '', + city: '', + postcode: '', + region: '', + country: '', + phone: '' + } if (this.currentUser) { if (this.currentUser && this.currentUser.hasOwnProperty('default_shipping')) { - let index - for (let i = 0; i < this.currentUser.addresses.length; i++) { - if (toString(this.currentUser.addresses[i].id) === toString(this.currentUser.default_shipping)) { - index = i - } - } - if (index >= 0) { - return { - firstName: this.currentUser.addresses[index].firstname, - lastName: this.currentUser.addresses[index].lastname, - street: this.currentUser.addresses[index].street[0], - house: this.currentUser.addresses[index].street[1], - city: this.currentUser.addresses[index].city, - postcode: this.currentUser.addresses[index].postcode, - region: this.currentUser.addresses[index].region.region ? this.currentUser.addresses[index].region.region : '', - country: this.currentUser.addresses[index].country_id, - phone: this.currentUser.addresses[index].hasOwnProperty('telephone') ? this.currentUser.addresses[index].telephone : '' - } - } + shippingDetails = this.readShippingDetailsFromCurrentUser(shippingDetails); } else { - return { - firstName: this.currentUser.firstname, - lastName: this.currentUser.lastname, - street: '', - house: '', - city: '', - postcode: '', - region: '', - country: '', - phone: '' - } - } - } else { - return { - firstName: '', - lastName: '', - street: '', - house: '', - city: '', - postcode: '', - region: '', - country: '', - phone: '' + shippingDetails.firstName = this.currentUser.firstname + shippingDetails.lastName = this.currentUser.lastname } } + return shippingDetails; }, getCountryName () { for (let i = 0; i < this.countries.length; i++) { diff --git a/core/modules/user/hooks.ts b/core/modules/user/hooks.ts new file mode 100644 index 000000000..5c50909f6 --- /dev/null +++ b/core/modules/user/hooks.ts @@ -0,0 +1,36 @@ +import { createListenerHook, createMutatorHook } from '@vue-storefront/core/lib/hooks' + +// Authorize + +const { + hook: afterUserAuthorizeHook, + executor: afterUserAuthorizeExecutor +} = createListenerHook() + +// Unauthorize + +const { + hook: afterUserUnauthorizeHook, + executor: afterUserUnauthorizeExecutor +} = createListenerHook() + +/** Only for internal usage in this module */ +const userHooksExecutors = { + afterUserAuthorize: afterUserAuthorizeExecutor, + afterUserUnauthorize: afterUserUnauthorizeExecutor +} + +const userHooks = { + /** Hook is fired right after user is authenticated or auth fails. + * @param response result of user authentication containing status codes and user data + */ + afterUserAuthorize: afterUserAuthorizeHook, + /** Hook is fired right after user is logged out. + */ + afterUserUnauthorize: afterUserUnauthorizeHook +} + +export { + userHooks, + userHooksExecutors +} diff --git a/core/modules/user/hooks/afterRegistration.ts b/core/modules/user/hooks/afterRegistration.ts deleted file mode 100644 index 7541c5190..000000000 --- a/core/modules/user/hooks/afterRegistration.ts +++ /dev/null @@ -1,58 +0,0 @@ -import Vue from 'vue' -import * as types from './../store/mutation-types' - -export async function afterRegistration ({ Vue, config, store, isServer }) { - if (!isServer) { - await store.dispatch('user/startSession') - - Vue.prototype.$bus.$on('user-before-logout', () => { - store.dispatch('user/logout', { silent: false }) - // TODO: Move it to theme - store.commit('ui/setSubmenu', { - depth: 0 - }) - }) - - Vue.prototype.$bus.$on('user-after-loggedin', receivedData => { - // TODO: Make independent of checkout module - store.dispatch('checkout/savePersonalDetails', { - firstName: receivedData.firstname, - lastName: receivedData.lastname, - emailAddress: receivedData.email - }) - }) - } - - store.subscribe((mutation, state) => { - const type = mutation.type - - if ( - type.endsWith(types.USER_INFO_LOADED) - ) { - Vue.prototype.$db.usersCollection.setItem('current-user', state.user.current).catch((reason) => { - console.error(reason) // it doesn't work on SSR - }) // populate cache - } - - if ( - type.endsWith(types.USER_ORDERS_HISTORY_LOADED) - ) { - Vue.prototype.$db.ordersHistoryCollection.setItem('orders-history', state.user.orders_history).catch((reason) => { - console.error(reason) // it doesn't work on SSR - }) // populate cache - } - - if ( - type.endsWith(types.USER_TOKEN_CHANGED) - ) { - Vue.prototype.$db.usersCollection.setItem('current-token', state.user.token).catch((reason) => { - console.error(reason) // it doesn't work on SSR - }) // populate cache - if (state.user.refreshToken) { - Vue.prototype.$db.usersCollection.setItem('current-refresh-token', state.user.refreshToken).catch((reason) => { - console.error(reason) // it doesn't work on SSR - }) // populate cache - } - } - }) -} diff --git a/core/modules/user/hooks/beforeRegistration.ts b/core/modules/user/hooks/beforeRegistration.ts deleted file mode 100644 index be68242e1..000000000 --- a/core/modules/user/hooks/beforeRegistration.ts +++ /dev/null @@ -1,20 +0,0 @@ -import * as localForage from 'localforage' -import UniversalStorage from '@vue-storefront/core/store/lib/storage' -import { currentStoreView } from '@vue-storefront/core/lib/multistore' - -export function beforeRegistration ({ Vue, config, store, isServer }) { - const storeView = currentStoreView() - const dbNamePrefix = storeView.storeCode ? storeView.storeCode + '-' : '' - - Vue.prototype.$db.usersCollection = new UniversalStorage(localForage.createInstance({ - name: (config.storeViews.commonCache ? '' : dbNamePrefix) + 'shop', - storeName: 'user', - driver: localForage[config.localForage.defaultDrivers['user']] - })) - - Vue.prototype.$db.ordersHistoryCollection = new UniversalStorage(localForage.createInstance({ - name: (config.storeViews.commonCache ? '' : dbNamePrefix) + 'shop', - storeName: 'ordersHistory', - driver: localForage[config.localForage.defaultDrivers['ordersHistory']] - })) -} diff --git a/core/modules/user/index.ts b/core/modules/user/index.ts index 4e75e98d2..cad4de154 100644 --- a/core/modules/user/index.ts +++ b/core/modules/user/index.ts @@ -1,14 +1,64 @@ -import { module } from './store' -import { createModule } from '@vue-storefront/core/lib/module' -import { beforeEach } from './router/beforeEach' -import { beforeRegistration } from './hooks/beforeRegistration' -import { afterRegistration } from './hooks/afterRegistration' - -export const KEY = 'user' -export const User = createModule({ - key: KEY, - store: { modules: [{ key: KEY, module }] }, - beforeRegistration, - afterRegistration, - router: { beforeEach } -}) +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 EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' +import * as types from './store/mutation-types' + +export const UserModule: StorefrontModule = async function ({store}) { + StorageManager.init('user') + store.registerModule('user', userStore) + if (!isServer) { + EventBus.$on('user-before-logout', () => { + store.dispatch('user/logout', { silent: false }) + // TODO: Move it to theme + store.commit('ui/setSubmenu', { + depth: 0 + }) + }) + + EventBus.$on('user-after-loggedin', receivedData => { + // TODO: Make independent of checkout module + store.dispatch('checkout/savePersonalDetails', { + firstName: receivedData.firstname, + lastName: receivedData.lastname, + emailAddress: receivedData.email + }) + }) + + store.dispatch('user/startSession') + } + + store.subscribe((mutation, state) => { + const type = mutation.type + + if ( + 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 + }) // populate cache + } + + if ( + 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 + }) // populate cache + } + + if ( + 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 + }) // 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 + }) // populate cache + } + } + }) +} diff --git a/core/modules/user/router/beforeEach.ts b/core/modules/user/router/beforeEach.ts deleted file mode 100644 index a1b41b45b..000000000 --- a/core/modules/user/router/beforeEach.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Route } from 'vue-router' -import rootStore from '@vue-storefront/core/store' -import { isServer } from '@vue-storefront/core/helpers' - -export async function beforeEach (to: Route, from: Route, next) { - const requiresAuth = to.matched.some(route => route.meta.requiresAuth) - if (requiresAuth) { - if (isServer) { - next() - } else { - await rootStore.dispatch('user/startSession') - if (!rootStore.getters['user/isLoggedIn']) { - next('/') - localStorage.setItem('redirect', from.path) - } else { - next() - } - } - } else { - next() - } -} diff --git a/core/modules/user/store/actions.ts b/core/modules/user/store/actions.ts index 109dde4bd..6e5af6876 100644 --- a/core/modules/user/store/actions.ts +++ b/core/modules/user/store/actions.ts @@ -1,382 +1,298 @@ -import Vue from 'vue' import { ActionTree } from 'vuex' import * as types from './mutation-types' -import rootStore from '@vue-storefront/core/store' import i18n from '@vue-storefront/i18n' -import { adjustMultistoreApiUrl } from '@vue-storefront/core/lib/multistore' import RootState from '@vue-storefront/core/types/RootState' import UserState from '../types/UserState' import { Logger } from '@vue-storefront/core/lib/logger' -import { TaskQueue } from '@vue-storefront/core/lib/sync' import { UserProfile } from '../types/UserProfile' -import { isServer, processURLAddress } from '@vue-storefront/core/helpers' -import config from 'config' -// import router from '@vue-storefront/core/router' +import { onlineHelper } from '@vue-storefront/core/helpers' +import { isServer } from '@vue-storefront/core/helpers' +import { UserService } from '@vue-storefront/core/data-resolver' +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' +import { userHooksExecutors, userHooks } from '../hooks' const actions: ActionTree = { - async startSession (context) { - if (isServer || context.getters.isLocalDataLoaded) return - const cache = Vue.prototype.$db.usersCollection + async startSession ({ commit, dispatch, getters }) { + const usersCollection = StorageManager.get('user') + const userData = await usersCollection.getItem('current-user') - const user = await cache.getItem(`current-user`) + if (isServer || getters.isLocalDataLoaded) return + commit(types.USER_LOCAL_DATA_LOADED, true) - if (user) { - context.commit(types.USER_INFO_LOADED, user) + if (userData) { + commit(types.USER_INFO_LOADED, userData) } - context.commit(types.USER_START_SESSION) - context.commit(types.USER_LOCAL_DATA_LOADED, true) + commit(types.USER_START_SESSION) + const lastUserToken = await usersCollection.getItem('current-token') - cache.getItem('current-token', (err, res) => { - if (err) { - Logger.error(err, 'user')() - return - } + if (lastUserToken) { + commit(types.USER_TOKEN_CHANGED, { newToken: lastUserToken }) + await dispatch('sessionAfterAuthorized', {}) - if (res) { - context.commit(types.USER_TOKEN_CHANGED, { newToken: res }) - context.dispatch('sessionAfterAuthorized') - - if (config.usePriceTiers) { - cache.getItem('current-user', (err, userData) => { - if (err) { - Logger.error(err, 'user')() - return - } - - if (userData) { - context.dispatch('setUserGroup', userData) - } - }) - } - } else { - Vue.prototype.$bus.$emit('session-after-nonauthorized') + if (userData) { + dispatch('setUserGroup', userData) } - Vue.prototype.$bus.$emit('session-after-started') - }) + } else { + EventBus.$emit('session-after-nonauthorized') + } + + EventBus.$emit('session-after-started') }, /** * Send password reset link for specific e-mail */ resetPassword (context, { email }) { - return TaskQueue.execute({ url: config.users.resetPassword_endpoint, - payload: { - method: 'POST', - mode: 'cors', - headers: { - 'Accept': 'application/json, text/plain, */*', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ email: email }) - } - }) + return UserService.resetPassword(email) }, /** * Login user and return user profile and current token */ - login (context, { username, password }) { - let url = config.users.login_endpoint - if (config.storeViews.multistore) { - url = adjustMultistoreApiUrl(url) + async login ({ commit, dispatch }, { username, password }) { + const resp = await UserService.login(username, password) + userHooksExecutors.afterUserAuthorize(resp) + + if (resp.code === 200) { + try { + await dispatch('resetUserInvalidateLock', {}, { root: true }) + commit(types.USER_TOKEN_CHANGED, { newToken: resp.result, meta: resp.meta }) // TODO: handle the "Refresh-token" header + await dispatch('sessionAfterAuthorized', { refresh: true, useCache: false }) + } catch (err) { + await dispatch('clearCurrentUser') + throw new Error(err) + } } - return fetch(processURLAddress(url), { method: 'POST', - mode: 'cors', - headers: { - 'Accept': 'application/json, text/plain, */*', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ username: username, password: password }) - }).then(resp => { return resp.json() }) - .then((resp) => { - if (resp.code === 200) { - rootStore.state.userTokenInvalidateLock = 0 - context.commit(types.USER_TOKEN_CHANGED, { newToken: resp.result, meta: resp.meta }) // TODO: handle the "Refresh-token" header - context.dispatch('me', { refresh: true, useCache: false }).then(result => {}) - context.dispatch('getOrdersHistory', { refresh: true, useCache: false }).then(result => {}) - } - return resp - }) + + return resp }, /** * Login user and return user profile and current token */ - async register (context, { email, firstname, lastname, password }) { - let url = config.users.create_endpoint - if (config.storeViews.multistore) { - url = adjustMultistoreApiUrl(url) - } - return fetch(processURLAddress(url), { method: 'POST', - mode: 'cors', - headers: { - 'Accept': 'application/json, text/plain, */*', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ customer: { email: email, firstname: firstname, lastname: lastname }, password: password }) - }).then(resp => { return resp.json() }) + async register (context, { password, ...customer }) { + return UserService.register(customer, password) }, /** * Invalidate user token */ - refresh (context) { - return new Promise((resolve, reject) => { - const usersCollection = Vue.prototype.$db.usersCollection - usersCollection.getItem('current-refresh-token', (err, refreshToken) => { - if (err) { - Logger.error(err, 'user')() - } - let url = config.users.refresh_endpoint - if (config.storeViews.multistore) { - url = adjustMultistoreApiUrl(url) - } - return fetch(processURLAddress(url), { method: 'POST', - mode: 'cors', - headers: { - 'Accept': 'application/json, text/plain, */*', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ refreshToken: refreshToken }) - }).then(resp => { return resp.json() }) - .then((resp) => { - if (resp.code === 200) { - context.commit(types.USER_TOKEN_CHANGED, { newToken: resp.result, meta: resp.meta ? resp.meta : null }) // TODO: handle the "Refresh-token" header - } - resolve(resp) - }).catch((exc) => reject(exc)) - }) - }) + async refresh ({ commit }) { + const usersCollection = StorageManager.get('user') + const refreshToken = await usersCollection.getItem('current-refresh-token') + const newToken = await UserService.refreshToken(refreshToken) + + if (newToken) { + commit(types.USER_TOKEN_CHANGED, { newToken }) + } + + return newToken }, /** * Update user groupToken and groupId in state * @param context * @param userData */ - setUserGroup (context, userData) { - if (config.usePriceTiers) { - if (userData.groupToken) { - context.commit(types.USER_GROUP_TOKEN_CHANGED, userData.groupToken) - } + setUserGroup ({ commit }, userData) { + if (userData.groupToken) { + commit(types.USER_GROUP_TOKEN_CHANGED, userData.groupToken) + } - if (userData.group_id) { - context.commit(types.USER_GROUP_CHANGED, userData.group_id) - } - } else { - context.commit(types.USER_GROUP_TOKEN_CHANGED, '') - context.commit(types.USER_GROUP_CHANGED, null) + if (userData.group_id) { + commit(types.USER_GROUP_CHANGED, userData.group_id) + } + }, + async restoreCurrentUserFromCache ({ commit, dispatch }) { + const usersCollection = StorageManager.get('user') + const currentUser = await usersCollection.getItem('current-user') + + if (currentUser) { + commit(types.USER_INFO_LOADED, currentUser) + await dispatch('setUserGroup', currentUser) + EventBus.$emit('user-after-loggedin', currentUser) + dispatch('cart/authorize', {}, { root: true }) + + return currentUser + } + + return null + }, + async refreshUserProfile ({ commit, dispatch }, { resolvedFromCache }) { + const resp = await UserService.getProfile() + + if (resp.resultCode === 200) { + commit(types.USER_INFO_LOADED, resp.result) // this also stores the current user to localForage + await dispatch('setUserGroup', resp.result) + } + + if (!resolvedFromCache && resp.resultCode === 200) { + EventBus.$emit('user-after-loggedin', resp.result) + dispatch('cart/authorize', {}, { root: true }) + return resp } }, /** * Load current user profile */ - me (context, { refresh = true, useCache = true } = {}) { - return new Promise((resolve, reject) => { - if (!context.state.token) { - Logger.warn('No User token, user unauthorized', 'user')() - return resolve(null) - } - const cache = Vue.prototype.$db.usersCollection - let resolvedFromCache = false - - if (useCache === true) { // after login for example we shouldn't use cache to be sure we're loading currently logged in user - cache.getItem('current-user', (err, res) => { - if (err) { - Logger.error(err, 'user')() - return - } - - if (res) { - context.commit(types.USER_INFO_LOADED, res) - context.dispatch('setUserGroup', res) - Vue.prototype.$bus.$emit('user-after-loggedin', res) - rootStore.dispatch('cart/authorize') - - resolve(res) - resolvedFromCache = true - Logger.log('Current user served from cache', 'user')() - } - }) - } + async me ({ dispatch, getters }, { refresh = true, useCache = true } = {}) { + if (!getters.getToken) { + Logger.warn('No User token, user unauthorized', 'user')() + return + } + + let resolvedFromCache = false + + if (useCache) { + const currentUser = await dispatch('restoreCurrentUserFromCache') - if (refresh) { - TaskQueue.execute({ url: config.users.me_endpoint, - payload: { method: 'GET', - mode: 'cors', - headers: { - 'Accept': 'application/json, text/plain, */*', - 'Content-Type': 'application/json' - } - } - }) - .then((resp: any) => { - if (resp.resultCode === 200) { - context.commit(types.USER_INFO_LOADED, resp.result) // this also stores the current user to localForage - context.dispatch('setUserGroup', resp.result) - } - if (!resolvedFromCache && resp.resultCode === 200) { - Vue.prototype.$bus.$emit('user-after-loggedin', resp.result) - rootStore.dispatch('cart/authorize') - resolve(resp) - } else { - resolve(null) - } - return resp - }) - } else { - if (!resolvedFromCache) { - resolve(null) - } + if (currentUser) { + resolvedFromCache = true + Logger.log('Current user served from cache', 'user')() } - }) + } + + if (refresh) { + return dispatch('refreshUserProfile', { resolvedFromCache }) + } }, /** * Update user profile with data from My Account page */ - async update (context, userData: UserProfile) { - await TaskQueue.queue({ - url: config.users.me_endpoint, - payload: { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - mode: 'cors', - body: JSON.stringify(userData) - }, - callback_event: 'store:user/userAfterUpdate' - }) + async update (_, profile: UserProfile) { + await UserService.updateProfile(profile, 'user/handleUpdateProfile') }, - setCurrentUser (context, userData) { - context.commit(types.USER_INFO_LOADED, userData) + async handleUpdateProfile ({ dispatch }, event) { + if (event.resultCode === 200) { + dispatch('notification/spawnNotification', { + type: 'success', + message: i18n.t('Account data has successfully been updated'), + action1: { label: i18n.t('OK') } + }, { root: true }) + dispatch('user/setCurrentUser', event.result, { root: true }) + } + }, + setCurrentUser ({ commit }, userData) { + commit(types.USER_INFO_LOADED, userData) }, /** * Change user password */ - changePassword (context, passwordData) { - return TaskQueue.execute({ url: config.users.changePassword_endpoint, - payload: { - method: 'POST', - mode: 'cors', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(passwordData) - } - }).then((resp: any) => { - if (resp.code === 200) { - rootStore.dispatch('notification/spawnNotification', { - type: 'success', - message: 'Password has successfully been changed', - action1: { label: i18n.t('OK') } - }) - - rootStore.dispatch('user/login', { - username: context.state.current.email, - password: passwordData.newPassword - }) - } else { - rootStore.dispatch('notification/spawnNotification', { - type: 'error', - message: i18n.t(resp.result), - action1: { label: i18n.t('OK') } - }) - } - }) + async changePassword ({ dispatch, getters }, passwordData) { + if (!onlineHelper.isOnline) { + dispatch('notification/spawnNotification', { + type: 'error', + message: i18n.t('Reset password feature does not work while offline!'), + action1: { label: i18n.t('OK') } + }, { root: true }) + + return + } + + const resp = await UserService.changePassword(passwordData) + + if (resp.code === 200) { + await dispatch('notification/spawnNotification', { + type: 'success', + message: 'Password has successfully been changed', + action1: { label: i18n.t('OK') } + }, { root: true }) + await dispatch('login', { + username: getters.getUserEmail, + password: passwordData.newPassword + }) + } else { + await dispatch('notification/spawnNotification', { + type: 'error', + message: i18n.t(resp.result.errorMessage), + action1: { label: i18n.t('OK') } + }, { root: true }) + } }, - clearCurrentUser (context) { - context.commit(types.USER_TOKEN_CHANGED, '') - context.commit(types.USER_GROUP_TOKEN_CHANGED, '') - context.commit(types.USER_GROUP_CHANGED, null) - context.commit(types.USER_INFO_LOADED, null) - context.dispatch('wishlist/clear', null, {root: true}) - context.dispatch('compare/clear', null, {root: true}) - context.dispatch('checkout/savePersonalDetails', {}, {root: true}) - context.dispatch('checkout/saveShippingDetails', {}, {root: true}) - context.dispatch('checkout/savePaymentDetails', {}, {root: true}) + clearCurrentUser ({ commit, dispatch }) { + commit(types.USER_TOKEN_CHANGED, '') + commit(types.USER_GROUP_TOKEN_CHANGED, '') + commit(types.USER_GROUP_CHANGED, null) + commit(types.USER_INFO_LOADED, null) + dispatch('wishlist/clear', null, { root: true }) + dispatch('compare/clear', null, {root: true}) + dispatch('checkout/savePersonalDetails', {}, { root: true }) + dispatch('checkout/saveShippingDetails', {}, { root: true }) + dispatch('checkout/savePaymentDetails', {}, { root: true }) }, /** * Logout user */ - logout (context, { silent = false }) { - context.commit(types.USER_END_SESSION) - 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 }) }) + async logout ({ commit, dispatch }, { silent = false }) { + commit(types.USER_END_SESSION) + await dispatch('cart/disconnect', {}, { root: true }) + await dispatch('clearCurrentUser') + EventBus.$emit('user-after-logout') + await dispatch('cart/clear', { recreateAndSyncCart: true }, { root: true }) + if (!silent) { - rootStore.dispatch('notification/spawnNotification', { + await dispatch('notification/spawnNotification', { type: 'success', message: i18n.t("You're logged out"), action1: { label: i18n.t('OK') } - }) + }, { root: true }) } + userHooksExecutors.afterUserUnauthorize() + }, + async loadOrdersFromCache ({ commit }) { + const ordersHistoryCollection = StorageManager.get('user') + const ordersHistory = await ordersHistoryCollection.getItem('orders-history') + + if (ordersHistory) { + commit(types.USER_ORDERS_HISTORY_LOADED, ordersHistory) + EventBus.$emit('user-after-loaded-orders', ordersHistory) + + return ordersHistory + } + }, + async refreshOrdersHistory ({ commit }, { resolvedFromCache, pageSize = 20, currentPage = 1 }) { + const resp = await UserService.getOrdersHistory(pageSize, currentPage) + + if (resp.code === 200) { + commit(types.USER_ORDERS_HISTORY_LOADED, resp.result) // this also stores the current user to localForage + EventBus.$emit('user-after-loaded-orders', resp.result) + } + + if (!resolvedFromCache) { + Promise.resolve(resp.code === 200 ? resp : null) + } + + return resp }, /** * Load user's orders history */ - getOrdersHistory (context, { refresh = true, useCache = true }) { - // TODO: Make it as an extension from users module - return new Promise((resolve, reject) => { - if (!context.state.token) { - Logger.debug('No User token, user unathorized', 'user')() - return resolve(null) - } - const cache = Vue.prototype.$db.ordersHistoryCollection - let resolvedFromCache = false - - if (useCache === true) { // after login for example we shouldn't use cache to be sure we're loading currently logged in user - cache.getItem('orders-history', (err, res) => { - if (err) { - Logger.error(err, 'user')() - return - } - - if (res) { - context.commit(types.USER_ORDERS_HISTORY_LOADED, res) - Vue.prototype.$bus.$emit('user-after-loaded-orders', res) - - resolve(res) - resolvedFromCache = true - Logger.log('Current user order history served from cache', 'user')() - } - }) + async getOrdersHistory ({ dispatch, getters }, { refresh = true, useCache = true, pageSize = 20, currentPage = 1 }) { + if (!getters.getToken) { + Logger.debug('No User token, user unathorized', 'user')() + return Promise.resolve(null) + } + let resolvedFromCache = false + + if (useCache) { + const ordersHistory = await dispatch('loadOrdersFromCache') + + if (ordersHistory) { + resolvedFromCache = true + Logger.log('Current user order history served from cache', 'user')() } + } - if (refresh) { - return TaskQueue.execute({ url: config.users.history_endpoint, - payload: { method: 'GET', - mode: 'cors', - headers: { - 'Accept': 'application/json, text/plain, */*', - 'Content-Type': 'application/json' - } - } - }).then((resp: any) => { - if (resp.code === 200) { - context.commit(types.USER_ORDERS_HISTORY_LOADED, resp.result) // this also stores the current user to localForage - Vue.prototype.$bus.$emit('user-after-loaded-orders', resp.result) - } - if (!resolvedFromCache) { - resolve(resp.code === 200 ? resp : null) - } - return resp - }) - } else { - if (!resolvedFromCache) { - resolve(null) - } + if (refresh) { + return dispatch('refreshOrdersHistory', { resolvedFromCache, pageSize, currentPage }) + } else { + if (!resolvedFromCache) { + Promise.resolve(null) } - }) - }, - userAfterUpdate (context, event) { - if (event.resultCode === 200) { - rootStore.dispatch('notification/spawnNotification', { - type: 'success', - message: i18n.t('Account data has successfully been updated'), - action1: { label: i18n.t('OK') } - }) - rootStore.dispatch('user/setCurrentUser', event.result) } }, - sessionAfterAuthorized (context, event) { + async sessionAfterAuthorized ({ dispatch }, { refresh = onlineHelper.isOnline, useCache = true }) { Logger.info('User session authorised ', 'user')() - rootStore.dispatch('user/me', { refresh: navigator.onLine }, { root: true }).then((us) => {}) // this will load user cart - rootStore.dispatch('user/getOrdersHistory', { refresh: navigator.onLine }, { root: true }).then((us) => {}) + await dispatch('me', { refresh, useCache }) + await dispatch('getOrdersHistory', { refresh, useCache }) } } diff --git a/core/modules/user/store/getters.ts b/core/modules/user/store/getters.ts index a02d7d1f8..4b60ec718 100644 --- a/core/modules/user/store/getters.ts +++ b/core/modules/user/store/getters.ts @@ -12,6 +12,12 @@ const getters: GetterTree = { }, getOrdersHistory (state) { return state.orders_history ? state.orders_history.items : [] + }, + getToken (state) { + return state.token + }, + getUserEmail (state, getters) { + return getters.isLoggedIn ? state.current.email : null } } diff --git a/core/modules/user/store/index.ts b/core/modules/user/store/index.ts index 94101758c..eca304504 100644 --- a/core/modules/user/store/index.ts +++ b/core/modules/user/store/index.ts @@ -5,7 +5,7 @@ import mutations from './mutations' import RootState from '@vue-storefront/core/types/RootState' import UserState from '../types/UserState' -export const module: Module = { +export const userStore: Module = { namespaced: true, state: { token: '', diff --git a/core/modules/user/test/unit/store/actions.spec.ts b/core/modules/user/test/unit/store/actions.spec.ts new file mode 100644 index 000000000..c1718ec3b --- /dev/null +++ b/core/modules/user/test/unit/store/actions.spec.ts @@ -0,0 +1,532 @@ +import * as types from '../../../store/mutation-types' +import * as data from './data' +import userActions from '../../../store/actions' +import {StorageManager} from '@vue-storefront/core/lib/storage-manager' +import {UserService} from '@vue-storefront/core/data-resolver' +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' + +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/lib/multistore', () => ({ + currentStoreView: jest.fn(() => ({ + storeCode: '2', + currentStoreView: jest.fn(), + localizedRoute: jest.fn() + })) +})); +jest.mock('@vue-storefront/core/helpers', () => ({ + get isServer () { + return false + }, + onlineHelper: { + isOnline: true + } +})); +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ + StorageManager: { + get: jest.fn() + } +})); +jest.mock('@vue-storefront/core/data-resolver', () => ({ + UserService: { + login: jest.fn(), + register: jest.fn(), + resetPassword: jest.fn(), + refreshToken: jest.fn(), + updateProfile: jest.fn(), + changePassword: jest.fn(), + getOrdersHistory: jest.fn() + } +})); +EventBus.$emit = jest.fn() + +describe('User actions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('startSession action', () => { + it('should NOT set user info', async () => { + (StorageManager.get as jest.Mock).mockImplementation(() => ({ + getItem: async () => (data.user) + })); + + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: {isLocalDataLoaded: true} + }; + const wrapper = (actions: any) => actions.startSession(contextMock); + + await wrapper(userActions); + + expect(contextMock.commit).not.toBeCalledWith(types.USER_INFO_LOADED, data.user) + }) + it('should star user session', async () => { + (StorageManager.get as jest.Mock).mockImplementation(() => ({ + getItem: async () => (data.user) + })); + + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: {isLocalDataLoaded: false} + }; + const wrapper = (actions: any) => actions.startSession(contextMock); + + await wrapper(userActions); + + expect(contextMock.commit).toBeCalledWith(types.USER_LOCAL_DATA_LOADED, true) + expect(contextMock.commit).toBeCalledWith(types.USER_INFO_LOADED, data.user) + expect(contextMock.commit).toBeCalledWith(types.USER_START_SESSION) + }) + it('should set user token', async () => { + (StorageManager.get as jest.Mock).mockImplementation(() => ({ + getItem: async () => (data.lastUserToken) + })); + + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: {isLocalDataLoaded: false} + }; + const wrapper = (actions: any) => actions.startSession(contextMock); + + await wrapper(userActions); + + expect(contextMock.commit).toBeCalledWith(types.USER_TOKEN_CHANGED, {newToken: data.lastUserToken}) + }) + it('should call setUserGroup action', async () => { + (StorageManager.get as jest.Mock).mockImplementation(() => ({ + getItem: async () => (data.user) + })); + + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: {isLocalDataLoaded: false} + }; + const wrapper = (actions: any) => actions.startSession(contextMock); + + await wrapper(userActions); + + expect(contextMock.dispatch).toBeCalledWith('setUserGroup', data.user) + }) + it('should emit session-after-nonauthorized', async () => { + (StorageManager.get as jest.Mock).mockImplementation(() => ({ + getItem: async () => (null) + })); + + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: {isLocalDataLoaded: false} + }; + const wrapper = (actions: any) => actions.startSession(contextMock); + + await wrapper(userActions); + + expect(EventBus.$emit).toBeCalledWith('session-after-nonauthorized') + }) + }); + + describe('resetPassword action', () => { + it('should return response from resetPassword', () => { + (UserService.resetPassword as jest.Mock).mockImplementation(() => + (data.responseOb) + ); + + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: {isLocalDataLoaded: false} + }; + const email = data.email; + const result = (userActions as any).resetPassword(contextMock, {email}); + + expect(result).toEqual(data.responseOb) + }) + }); + + describe('login action', () => { + it('should return login response', async () => { + (UserService.login as jest.Mock).mockImplementation(async () => + (data.responseOb) + ); + + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: {isLocalDataLoaded: false} + }; + const refreshValue = data.refresh; + const useCacheValue = !data.useCache; + const rootValue = true; + const username = data.username; + const password = data.password; + const result = await (userActions as any).login(contextMock, {username, password}); + + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'resetUserInvalidateLock', {}, {root: rootValue}) + expect(contextMock.commit).toHaveBeenCalledWith(types.USER_TOKEN_CHANGED, { + newToken: data.responseOb.result, + meta: data.responseOb.meta + }) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(2, 'sessionAfterAuthorized', { + refresh: refreshValue, + useCache: useCacheValue + }) + expect(contextMock.dispatch).not.toBeCalledWith('clearCurrentUser'); + expect(result).toEqual(data.responseOb) + }) + }); + + describe('register action', () => { + it('should return response from register', async () => { + (UserService.register as jest.Mock).mockImplementation(async () => + (data.responseOb) + ); + + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: {isLocalDataLoaded: false} + }; + const password = data.password; + const customer = data.customer; + const result = await (userActions as any).register(contextMock, {password, customer}); + + expect(result).toEqual(data.responseOb) + }) + }); + + describe('refresh action', () => { + it('should update user token', async () => { + (StorageManager.get as jest.Mock).mockImplementation(() => ({ + getItem: async () => (data.lastUserToken) + })); + (UserService.refreshToken as jest.Mock).mockImplementation(async () => + (data.lastUserToken) + ) + + const contextMock = { + commit: jest.fn() + } + const newToken = data.lastUserToken + const result = await (userActions as any).refresh(contextMock) + + expect(contextMock.commit).toBeCalledWith(types.USER_TOKEN_CHANGED, {newToken}); + expect(result).toEqual(newToken) + }) + }); + + describe('setUserGroup action', () => { + it('should update user groupToken and groupId in state', () => { + const contextMock = { + commit: jest.fn() + } + const wrapper = (actions: any) => actions.setUserGroup(contextMock, data.user); + + wrapper(userActions); + + expect(contextMock.commit).toHaveBeenNthCalledWith(1, types.USER_GROUP_TOKEN_CHANGED, data.user.groupToken) + expect(contextMock.commit).toHaveBeenNthCalledWith(2, types.USER_GROUP_CHANGED, data.user.group_id) + }) + }); + + describe('restoreCurrentUserFromCache', () => { + it('should restore current user from cache', async () => { + (StorageManager.get as jest.Mock).mockImplementation(() => ({ + getItem: async () => (data.user) + })); + + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: {isLocalDataLoaded: false} + }; + const result = await (userActions as any).restoreCurrentUserFromCache(contextMock) + + expect(contextMock.commit).toHaveBeenCalledWith(types.USER_INFO_LOADED, data.user) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'setUserGroup', data.user) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(2, 'cart/authorize', {}, {root: true}) + expect(result).toEqual(data.user) + }) + it('should return null if is not cached', async () => { + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: {isLocalDataLoaded: false} + }; + const result = await (userActions as any).restoreCurrentUserFromCache(contextMock) + + expect(result).toEqual(data.user) + }) + }); + + describe('me action', () => { + it('should NOT dispatch restoreCurrentUserFromCache', async () => { + const contextMock = { + dispatch: jest.fn(), + getters: jest.fn() + } + + await (userActions as any).me(contextMock) + + expect(contextMock.dispatch).not.toBeCalledWith('restoreCurrentUserFromCache') + }) + it('should load current user profile if getToken is not empty', async () => { + const contextMock = { + dispatch: jest.fn(), + getters: {getToken: data.lastUserToken} + } + const resolvedFromCache = !data.resolvedFromCache + + await (userActions as any).me(contextMock) + + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'restoreCurrentUserFromCache') + expect(contextMock.dispatch).toHaveBeenNthCalledWith(2, 'refreshUserProfile', {resolvedFromCache}) + }) + }); + + describe('update action', () => { + it('should set current result if resultCode from response is 200', async () => { + const responseOb = { + resultCode: 200, + result: 200 + }; + const contextMock = { + dispatch: jest.fn() + } + + await (userActions as any).handleUpdateProfile(contextMock, responseOb) + + expect(contextMock.dispatch).toHaveBeenCalledWith('user/setCurrentUser', responseOb.result, {root: true}) + }) + }); + + describe('setCurrentUser action', () => { + it('should set current user', () => { + const contextMock = { + commit: jest.fn() + }; + + (userActions as any).setCurrentUser(contextMock, data.user) + + expect(contextMock.commit).toBeCalledWith(types.USER_INFO_LOADED, data.user) + }) + }); + + describe('changePassword action', () => { + it('should call login action if response code is 200', async () => { + (UserService.changePassword as jest.Mock).mockImplementation(async () => + (data.responseOb) + ); + + const contextMock = { + dispatch: jest.fn(), + getters: {getUserEmail: data.email} + } + const passwordData = { + currentPassword: data.password, + newPassword: 'newPassword1' + } + + await (userActions as any).changePassword(contextMock, passwordData) + + expect(contextMock.dispatch).toBeCalledWith('login', { + username: data.user.email, + password: passwordData.newPassword + }) + }) + it('should call spawnNotification if response code is not 200', async () => { + const responseOb = { + code: 400, + result: { + errorMessage: 'Error' + } + }; + (UserService.changePassword as jest.Mock).mockImplementation(async () => + (responseOb) + ); + + const contextMock = { + dispatch: jest.fn() + } + const passwordData = { + currentPassword: data.password, + newPassword: 'newPassword1' + } + + await (userActions as any).changePassword(contextMock, passwordData) + + expect(contextMock.dispatch).toBeCalledWith('notification/spawnNotification', { + type: 'error', + message: responseOb.result.errorMessage, + action1: {label: 'OK'} + }, {root: true}) + }) + }); + + describe('clearCurrentUser action', () => { + it('should clear current user', () => { + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn() + }; + + (userActions as any).clearCurrentUser(contextMock) + + expect(contextMock.commit).toHaveBeenNthCalledWith(1, types.USER_TOKEN_CHANGED, '') + expect(contextMock.commit).toHaveBeenNthCalledWith(2, types.USER_GROUP_TOKEN_CHANGED, '') + expect(contextMock.commit).toHaveBeenNthCalledWith(3, types.USER_GROUP_CHANGED, null) + expect(contextMock.commit).toHaveBeenNthCalledWith(4, types.USER_INFO_LOADED, null) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'wishlist/clear', null, {root: true}) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(2, 'compare/clear', null, {root: true}) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(3, 'checkout/savePersonalDetails', {}, {root: true}) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(4, 'checkout/saveShippingDetails', {}, {root: true}) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(5, 'checkout/savePaymentDetails', {}, {root: true}) + }) + }); + + describe('logout action', () => { + it('should logout user', async () => { + const contextMock = { + commit: jest.fn(), + dispatch: jest.fn() + }; + const silent = false + + await (userActions as any).logout(contextMock, silent) + + expect(contextMock.commit).toBeCalledWith(types.USER_END_SESSION) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'cart/disconnect', {}, {root: true}) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(2, 'clearCurrentUser') + expect(contextMock.dispatch).toHaveBeenNthCalledWith(3, 'cart/clear', {recreateAndSyncCart: true}, {root: true}) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(4, 'notification/spawnNotification', { + type: 'success', + message: "You're logged out", + action1: {label: 'OK'} + }, {root: true}) + }) + }); + + describe('loadOrdersFromCache action', () => { + it('should return ordersHistory', async () => { + (StorageManager.get as jest.Mock).mockImplementation(() => ({ + getItem: async () => (data.ordersHistory) + })); + + const contextMock = { + commit: jest.fn() + } + + const result = await (userActions as any).loadOrdersFromCache(contextMock) + + expect(contextMock.commit).toBeCalledWith(types.USER_ORDERS_HISTORY_LOADED, data.ordersHistory) + expect(result).toBe(data.ordersHistory) + }) + }); + + describe('refreshOrderHistory action', () => { + it('should refresh orders history', async () => { + const responseOb = { + result: data.ordersHistory, + code: 200 + }; + (UserService.getOrdersHistory as jest.Mock).mockImplementation(async () => + (responseOb) + ); + + const contextMock = { + commit: jest.fn() + } + const resolvedFromCache = data.resolvedFromCache; + const pageSize = data.pageSize; + const currentPage = data.currentPage; + const result = await (userActions as any).refreshOrdersHistory(contextMock, { + resolvedFromCache, + pageSize, + currentPage + }) + + expect(contextMock.commit).toBeCalledWith(types.USER_ORDERS_HISTORY_LOADED, responseOb.result) + expect(result).toBe(responseOb) + }) + }); + + describe('getOrdersHistory action', () => { + it('should return null from the resolved promise if getToken is empty', async () => { + const contextMock = { + dispatch: jest.fn(), + getters: { + getToken: null + } + } + const refresh = data.refresh; + const useCache = data.useCache; + const pageSize = data.pageSize; + const currentPage = data.currentPage; + const result = await (userActions as any).getOrdersHistory(contextMock, { + refresh, + useCache, + pageSize, + currentPage + }) + + expect(result).toBe(null) + }) + it('should dispatch loadOrdersFromCache if useCache is set to true and refreshOrdersHistory action if refresh is set to true', async () => { + const contextMock = { + dispatch: jest.fn(), + getters: { + dispatch: jest.fn(), + getToken: data.lastUserToken + } + } + const refresh = data.refresh; + const useCache = data.useCache; + const pageSize = data.pageSize; + const resolvedFromCache = data.resolvedFromCache; + const currentPage = data.currentPage; + + contextMock.dispatch.mockImplementationOnce(() => Promise.resolve(data.ordersHistory)) + + await (userActions as any).getOrdersHistory(contextMock, {refresh, useCache, pageSize, currentPage}) + + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'loadOrdersFromCache') + expect(contextMock.dispatch).toHaveBeenNthCalledWith(2, 'refreshOrdersHistory', { + resolvedFromCache, + pageSize, + currentPage + }) + }) + }); + + describe('sessionAfterAuthorized action', () => { + it('should call me and getOrdersHistory after authorized', async () => { + const contextMock = { + dispatch: jest.fn() + } + const refresh = data.refresh; + const useCache = data.useCache; + + await (userActions as any).sessionAfterAuthorized(contextMock, {refresh, useCache}) + + expect(contextMock.dispatch).toHaveBeenNthCalledWith(1, 'me', {refresh, useCache}) + expect(contextMock.dispatch).toHaveBeenNthCalledWith(2, 'getOrdersHistory', {refresh, useCache}) + }) + }) +}) diff --git a/core/modules/user/test/unit/store/data.ts b/core/modules/user/test/unit/store/data.ts new file mode 100644 index 000000000..85347ec41 --- /dev/null +++ b/core/modules/user/test/unit/store/data.ts @@ -0,0 +1,82 @@ +let user = { + id: 58, + group_id: 1, + groupToken: 'group-three', + default_billing: '62', + default_shipping: '48', + created_at: '2018-01-23 15:30:00', + updated_at: '2018-03-04 06:39:28', + created_in: 'Default Store View', + email: 'examplename@example.com', + firstname: 'ExampleFirstName', + lastname: 'ExampleLastName', + store_id: 1, + website_id: 1, + addresses: [ + { + id: 48, + customer_id: 58, + region: { + region_code: null, + region: null, + region_id: 0 + }, + region_id: 0, + country_id: 'CountryId', + street: ['Street', '12'], + telephone: '', + postcode: '51-169', + city: 'City', + firstname: 'ExampleFirstName', + lastname: 'ExampleLastName', + default_shipping: true + }, + { + id: 62, + customer_id: 58, + region: { + region_code: null, + region: null, + region_id: 0 + }, + region_id: 0, + country_id: 'CountryId', + street: ['Street', '12'], + company: 'example', + telephone: '', + postcode: '51-169', + city: 'City', + firstname: 'ExampleFirstName', + lastname: 'ExampleLastName', + vat_id: 'vatidhere42342', + default_billing: true + } + ], + 'disable_auto_group_change': 0 +}; +let lastUserToken: string = 'current-refresh-token'; +let responseOb = { + code: 200, + result: lastUserToken, + meta: 'meta' +}; +let email: string = 'examplename@example.com'; +let username: string = 'username'; +let password: string = 'Password456'; +let customer = { + email: 'examplename@example.com', + firstname: 'ExampleFirstName', + lastname: 'ExampleLastName', + addresses: 'addr' +}; +let ordersHistory = 'orders-history'; +let refresh: boolean = true; +let useCache: boolean = true; +let resolvedFromCache: boolean = true; +let pageSize: number = 20; +let currentPage: number = 1; + +export { + user, lastUserToken, responseOb, email, username, password, customer, + ordersHistory, refresh, useCache, resolvedFromCache, pageSize, currentPage +} diff --git a/core/modules/user/test/unit/store/mutations.spec.ts b/core/modules/user/test/unit/store/mutations.spec.ts new file mode 100644 index 000000000..58ff2f8f8 --- /dev/null +++ b/core/modules/user/test/unit/store/mutations.spec.ts @@ -0,0 +1,176 @@ +import * as types from '../../../store/mutation-types' +import userMutations from '../../../store/mutations' +import * as data from './data' + +jest.mock('@vue-storefront/core/lib/logger', () => ({ + Logger: { + log: jest.fn(() => () => { + }) + } +})); + +describe('User mutations', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('USER_TOKEN_CHANGED', () => { + it('should assign new user token', () => { + const stateMock = { + token: '' + } + const expectedState = { + token: data.lastUserToken + } + const wrapper = (mutations: any) => mutations[types.USER_TOKEN_CHANGED](stateMock, {newToken: data.lastUserToken}) + + wrapper(userMutations) + + expect(stateMock).toEqual(expectedState) + }) + it('should assign new token and new refreshToken', () => { + const stateMock = { + token: '', + refreshToken: '' + } + const expectedState = { + token: data.lastUserToken, + refreshToken: 'refresh-token' + } + const wrapper = (mutations: any) => mutations[types.USER_TOKEN_CHANGED](stateMock, { + newToken: data.lastUserToken, + meta: {refreshToken: 'refresh-token'} + }) + + wrapper(userMutations) + + expect(stateMock).toEqual(expectedState) + }) + }) + + describe('USER_START_SESSION', () => { + + it('should assign session_started', () => { + + jest.isolateModules(() => { + + let dateTest = new Date(Date.now()); + jest + .spyOn(global, 'Date') + .mockImplementationOnce(() => dateTest.toDateString()); + + const stateMock = { + session_started: new Date + } + const expectedState = { + session_started: new Date + } + const wrapper = (mutations: any) => mutations[types.USER_START_SESSION](stateMock) + + wrapper(userMutations) + + expect(stateMock).toEqual(expectedState) + }) + }) + }) + + describe('USER_GROUP_TOKEN_CHANGED', () => { + it('should assign token to groupToken', () => { + const stateMock = { + groupToken: '' + } + const expectedState = { + groupToken: data.user.groupToken + } + const wrapper = (mutations: any) => mutations[types.USER_GROUP_TOKEN_CHANGED](stateMock, data.user.groupToken) + + wrapper(userMutations) + + expect(stateMock).toEqual(expectedState) + }) + }) + + describe('USER_GROUP_CHANGED', () => { + it('should assign groupid', () => { + const stateMock = { + groupId: null + } + const expectedState = { + groupId: data.user.group_id + } + const wrapper = (mutations: any) => mutations[types.USER_GROUP_CHANGED](stateMock, data.user.group_id) + + wrapper(userMutations) + + expect(stateMock).toEqual(expectedState) + }) + }) + + describe('USER_INFO_LOADED', () => { + it('should assign current user', () => { + const stateMock = { + current: null + } + const expectedState = { + current: data.user + } + const wrapper = (mutations: any) => mutations[types.USER_INFO_LOADED](stateMock, data.user) + + wrapper(userMutations) + + expect(stateMock).toEqual(expectedState) + }) + }) + + describe('USER_ORDERS_HISTORY_LOADED', () => { + it('should assign orders history', () => { + const stateMock = { + orders_history: null + } + const expectedState = { + orders_history: data.ordersHistory + } + const wrapper = (mutations: any) => mutations[types.USER_ORDERS_HISTORY_LOADED](stateMock, data.ordersHistory) + + wrapper(userMutations) + + expect(stateMock).toEqual(expectedState) + }) + }) + + describe('USER_END_SESSION', () => { + it('should clear current user token, current user info and session_started', () => { + const stateMock = { + token: data.lastUserToken, + current: data.user, + session_started: new Date() + } + const expectedState = { + token: '', + current: null, + session_started: null + } + const wrapper = (mutations: any) => mutations[types.USER_END_SESSION](stateMock) + + wrapper(userMutations) + + expect(stateMock).toEqual(expectedState) + }) + }) + + describe('USER_LOCAL_DATA_LOADED', () => { + it('should assign readed boolean value to local_data_loaded', () => { + const stateMock = { + local_data_loaded: false + } + const expectedState = { + local_data_loaded: true + } + const wrapper = (mutations: any) => mutations[types.USER_LOCAL_DATA_LOADED](stateMock, true) + + wrapper(userMutations) + + expect(stateMock).toEqual(expectedState) + }) + }) +}) diff --git a/core/modules/user/types/UserProfile.ts b/core/modules/user/types/UserProfile.ts index 59ef8a761..61885a599 100644 --- a/core/modules/user/types/UserProfile.ts +++ b/core/modules/user/types/UserProfile.ts @@ -3,7 +3,7 @@ export interface UserProfile { email: string, firstname: string, lastname: string, - website_id?: number, + website_id?: number | string, addresses?: { firstname: string, lastname: string, diff --git a/core/modules/wishlist/components/AddToWishlist.ts b/core/modules/wishlist/components/AddToWishlist.ts index 00508abc8..8064358f5 100644 --- a/core/modules/wishlist/components/AddToWishlist.ts +++ b/core/modules/wishlist/components/AddToWishlist.ts @@ -1,6 +1,7 @@ import Product from '@vue-storefront/core/modules/catalog/types/Product' -import { Wishlist as WishlistModule } from '../' +import { WishlistModule } from '../' import wishlistMountedMixin from '@vue-storefront/core/modules/wishlist/mixins/wishlistMountedMixin' +import { registerModule } from '@vue-storefront/core/lib/modules'; export const AddToWishlist = { name: 'AddToWishlist', @@ -12,7 +13,7 @@ export const AddToWishlist = { } }, created () { - WishlistModule.register() + registerModule(WishlistModule) }, methods: { addToWishlist (product: Product) { diff --git a/core/modules/wishlist/components/IsOnWishlist.ts b/core/modules/wishlist/components/IsOnWishlist.ts index 8319b723c..db8d65049 100644 --- a/core/modules/wishlist/components/IsOnWishlist.ts +++ b/core/modules/wishlist/components/IsOnWishlist.ts @@ -1,5 +1,6 @@ -import { Wishlist as WishlistModule } from '../' +import { WishlistModule } from '../' import wishlistMountedMixin from '@vue-storefront/core/modules/wishlist/mixins/wishlistMountedMixin' +import { registerModule } from '@vue-storefront/core/lib/modules'; export const IsOnWishlist = { name: 'isOnWishlist', @@ -11,11 +12,11 @@ export const IsOnWishlist = { } }, created () { - WishlistModule.register() + registerModule(WishlistModule) }, computed: { - isOnWishlist (): boolean { - return !!this.$store.state.wishlist.items.find(p => p.sku === this.product.sku) || false + isOnWishlist () { + return this.$store.getters['wishlist/isOnWishlist'](this.product) } } } diff --git a/core/modules/wishlist/components/RemoveFromWishlist.ts b/core/modules/wishlist/components/RemoveFromWishlist.ts index 7c3c843c3..a359e0266 100644 --- a/core/modules/wishlist/components/RemoveFromWishlist.ts +++ b/core/modules/wishlist/components/RemoveFromWishlist.ts @@ -1,6 +1,7 @@ import Product from '@vue-storefront/core/modules/catalog/types/Product' -import { Wishlist as WishlistModule } from '../' +import { WishlistModule } from '../' import wishlistMountedMixin from '@vue-storefront/core/modules/wishlist/mixins/wishlistMountedMixin' +import { registerModule } from '@vue-storefront/core/lib/modules'; export const RemoveFromWishlist = { name: 'RemoveFromWishlist', @@ -13,7 +14,7 @@ export const RemoveFromWishlist = { }, methods: { removeFromWishlist (product: Product) { - WishlistModule.register() + registerModule(WishlistModule) this.$store.dispatch('wishlist/removeItem', product) } } diff --git a/core/modules/wishlist/components/Wishlist.ts b/core/modules/wishlist/components/Wishlist.ts index 8087a2a89..0fbb366ee 100644 --- a/core/modules/wishlist/components/Wishlist.ts +++ b/core/modules/wishlist/components/Wishlist.ts @@ -1,11 +1,12 @@ -import { Wishlist as WishlistModule } from '../' +import { WishlistModule } from '../' import wishlistMountedMixin from '@vue-storefront/core/modules/wishlist/mixins/wishlistMountedMixin' +import { registerModule } from '@vue-storefront/core/lib/modules'; export const Wishlist = { name: 'Wishlist', mixins: [wishlistMountedMixin], created () { - WishlistModule.register() + registerModule(WishlistModule) }, computed: { isWishlistOpen () { diff --git a/core/modules/wishlist/components/WishlistButton.ts b/core/modules/wishlist/components/WishlistButton.ts index e11eab34e..3475856af 100644 --- a/core/modules/wishlist/components/WishlistButton.ts +++ b/core/modules/wishlist/components/WishlistButton.ts @@ -1,4 +1,11 @@ +import wishlistMountedMixin from '@vue-storefront/core/modules/wishlist/mixins/wishlistMountedMixin' +import { mapGetters } from 'vuex' + export const WishlistButton = { + mixins: [wishlistMountedMixin], + computed: { + ...mapGetters('wishlist', ['getWishlistItemsCount']) + }, methods: { toggleWishlist () { this.$store.dispatch('ui/toggleWishlist') diff --git a/core/modules/wishlist/index.ts b/core/modules/wishlist/index.ts index 5a5824274..1183dd8d4 100644 --- a/core/modules/wishlist/index.ts +++ b/core/modules/wishlist/index.ts @@ -1,12 +1,10 @@ -import { module } from './store' -import { plugin } from './store/plugin' -import { createModule } from '@vue-storefront/core/lib/module' -import { initCacheStorage } from '@vue-storefront/core/helpers/initCacheStorage' +import { StorefrontModule } from '@vue-storefront/core/lib/modules' +import { wishlistStore } from './store' +import whishListPersistPlugin from './store/whishListPersistPlugin' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' -export const KEY = 'wishlist' -export const cacheStorage = initCacheStorage(KEY) -export const Wishlist = createModule({ - key: KEY, - store: { modules: [{ key: KEY, module }], plugin } +export const WishlistModule: StorefrontModule = function ({store}) { + StorageManager.init('wishlist') + store.registerModule('wishlist', wishlistStore) + store.subscribe(whishListPersistPlugin) } -) diff --git a/core/modules/wishlist/store/actions.ts b/core/modules/wishlist/store/actions.ts index e072d5e5e..3ab121816 100644 --- a/core/modules/wishlist/store/actions.ts +++ b/core/modules/wishlist/store/actions.ts @@ -1,42 +1,28 @@ -import Vue from 'vue' import { ActionTree } from 'vuex' import * as types from './mutation-types' -import i18n from '@vue-storefront/i18n' -import { htmlDecode } from '@vue-storefront/core/store/lib/filters' -import rootStore from '@vue-storefront/core/store' import RootState from '@vue-storefront/core/types/RootState' import WishlistState from '../types/WishlistState' -import { cacheStorage } from '../' -import { Logger } from '@vue-storefront/core/lib/logger' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' const actions: ActionTree = { clear (context) { - context.commit(types.WISH_LOAD_WISH, []) + context.commit(types.WISH_DEL_ALL_ITEMS, []) }, - load ({ commit, getters }, force: boolean = false) { + async load ({ commit, getters, dispatch }, force: boolean = false) { if (!force && getters.isWishlistLoaded) return commit(types.SET_WISHLIST_LOADED) - cacheStorage.getItem('current-wishlist', (err, storedItems) => { - if (err) throw new Error(err) - commit(types.WISH_LOAD_WISH, storedItems) - Logger.info('Wishlist state loaded from browser cache. ', 'cache', storedItems)() - }) + const storedItems = await dispatch('loadFromCache') + commit(types.WISH_LOAD_WISH, storedItems) + }, + loadFromCache () { + const wishlistStorage = StorageManager.get('wishlist') + return wishlistStorage.getItem('current-wishlist') }, addItem ({ commit }, product) { commit(types.WISH_ADD_ITEM, { product }) - rootStore.dispatch('notification/spawnNotification', { - type: 'success', - message: i18n.t('Product {productName} has been added to wishlist!', { productName: htmlDecode(product.name) }), - action1: { label: i18n.t('OK') } - }) }, removeItem ({ commit }, product) { commit(types.WISH_DEL_ITEM, { product }) - rootStore.dispatch('notification/spawnNotification', { - type: 'success', - message: i18n.t('Product {productName} has been removed from wishlit!', { productName: htmlDecode(product.name) }), - action1: { label: i18n.t('OK') } - }) } } diff --git a/core/modules/wishlist/store/getters.ts b/core/modules/wishlist/store/getters.ts new file mode 100644 index 000000000..f7708ca2a --- /dev/null +++ b/core/modules/wishlist/store/getters.ts @@ -0,0 +1,12 @@ +import { GetterTree } from 'vuex' +import RootState from '@vue-storefront/core/types/RootState' +import WishlistState from '../types/WishlistState' + +const getters: GetterTree = { + isOnWishlist: state => product => + state.items.some(p => p.sku === product.sku), + isWishlistLoaded: state => state.loaded, + getWishlistItemsCount: state => state.items.length +} + +export default getters diff --git a/core/modules/wishlist/store/index.ts b/core/modules/wishlist/store/index.ts index 8fc0561fd..ff63f9ac3 100644 --- a/core/modules/wishlist/store/index.ts +++ b/core/modules/wishlist/store/index.ts @@ -1,10 +1,11 @@ import { Module } from 'vuex' import actions from './actions' import mutations from './mutations' +import getters from './getters' import RootState from '@vue-storefront/core/types/RootState' import WishlistState from '../types/WishlistState' -export const module: Module = { +export const wishlistStore: Module = { namespaced: true, state: { loaded: false, @@ -12,7 +13,5 @@ export const module: Module = { }, actions, mutations, - getters: { - isWishlistLoaded: state => state.loaded - } + getters } diff --git a/core/modules/wishlist/store/mutation-types.ts b/core/modules/wishlist/store/mutation-types.ts index 42b04880d..324b607a7 100644 --- a/core/modules/wishlist/store/mutation-types.ts +++ b/core/modules/wishlist/store/mutation-types.ts @@ -1,5 +1,6 @@ export const SN_WISHLIST = 'wishlist' export const WISH_ADD_ITEM = SN_WISHLIST + '/ADD' export const WISH_DEL_ITEM = SN_WISHLIST + '/DEL' +export const WISH_DEL_ALL_ITEMS = SN_WISHLIST + '/DEL_ALL' export const WISH_LOAD_WISH = SN_WISHLIST + '/LOAD' export const SET_WISHLIST_LOADED = `${SN_WISHLIST}/SET_WISHLIST_LOADED` diff --git a/core/modules/wishlist/store/mutations.ts b/core/modules/wishlist/store/mutations.ts index a4ede2160..547cc78d2 100644 --- a/core/modules/wishlist/store/mutations.ts +++ b/core/modules/wishlist/store/mutations.ts @@ -3,10 +3,6 @@ import * as types from './mutation-types' import WishlistState from '../types/WishlistState' const mutations: MutationTree = { - /** - * Add product to Wishlist - * @param {Object} product data format for products is described in /doc/ElasticSearch data formats.md - */ [types.WISH_ADD_ITEM] (state, { product }) { const record = state.items.find(p => p.sku === product.sku) if (!record) { @@ -19,9 +15,12 @@ const mutations: MutationTree = { [types.WISH_DEL_ITEM] (state, { product }) { state.items = state.items.filter(p => p.sku !== product.sku) }, - [types.WISH_LOAD_WISH] (state, storedItems) { + [types.WISH_LOAD_WISH] (state, storedItems = []) { state.items = storedItems || [] }, + [types.WISH_DEL_ALL_ITEMS] (state) { + state.items = [] + }, [types.SET_WISHLIST_LOADED] (state, isLoaded: boolean = true) { state.loaded = isLoaded } diff --git a/core/modules/wishlist/store/plugin.ts b/core/modules/wishlist/store/plugin.ts deleted file mode 100644 index e5354c1e4..000000000 --- a/core/modules/wishlist/store/plugin.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as types from './mutation-types' -import { cacheStorage } from '../' -import { Logger } from '@vue-storefront/core/lib/logger' - -export function plugin (mutation, state) { - const type = mutation.type - if (type.includes(types.WISH_ADD_ITEM) || type.includes(types.WISH_DEL_ITEM)) { // check if this mutation is wishlist related - cacheStorage.setItem('current-wishlist', state.wishlist.items).catch((reason) => { - Logger.error(reason, 'wishlist') // it doesn't work on SSR - }) - } -} diff --git a/core/modules/wishlist/store/whishListPersistPlugin.ts b/core/modules/wishlist/store/whishListPersistPlugin.ts new file mode 100644 index 000000000..2815a2d2b --- /dev/null +++ b/core/modules/wishlist/store/whishListPersistPlugin.ts @@ -0,0 +1,15 @@ +import * as types from './mutation-types' +import { StorageManager } from '@vue-storefront/core/lib/storage-manager' + +const mutationToWatch = [types.WISH_ADD_ITEM, types.WISH_DEL_ITEM, types.WISH_DEL_ALL_ITEMS] + .map(m => `wishlist/${m}`) + +const whishListPersistPlugin = (mutation, state) => { + const whishListStorage = StorageManager.get('wishlist') + + if (mutationToWatch.includes(mutation.type)) { + whishListStorage.setItem('current-wishlist', state.wishlist.items) + } +} + +export default whishListPersistPlugin diff --git a/core/modules/wishlist/test/unit/components/AddToWishlist.spec.ts b/core/modules/wishlist/test/unit/components/AddToWishlist.spec.ts new file mode 100644 index 000000000..00963618b --- /dev/null +++ b/core/modules/wishlist/test/unit/components/AddToWishlist.spec.ts @@ -0,0 +1,61 @@ +import { mountMixin, mountMixinWithStore } from '@vue-storefront/unit-tests/utils'; +import { registerModule } from '@vue-storefront/core/lib/modules'; +import { WishlistModule } from '@vue-storefront/core/modules/wishlist'; +import { AddToWishlist } from '@vue-storefront/core/modules/wishlist/components/AddToWishlist'; + +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({})); +jest.mock('@vue-storefront/core/lib/modules', () => ({ registerModule: jest.fn() })); +jest.mock('@vue-storefront/core/helpers', () => ({ once: () => ({}) })); +jest.mock('@vue-storefront/core/modules/wishlist/store', () => ({})); +jest.mock('@vue-storefront/core/modules/wishlist/store/whishListPersistPlugin', () => ({})); +jest.mock('@vue-storefront/core/modules/wishlist/mixins/wishlistMountedMixin', () => ({})); + +describe('AddToWishlist', () => { + let product; + + beforeEach(() => { + jest.clearAllMocks(); + product = { + sku: 'example_sku', + image: 'example_image' + }; + }); + + it('creates a component', () => { + const wrapper = mountMixin(AddToWishlist, { + propsData: { product } + }); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.isVueInstance()).toBe(true); + }); + + it('component has been registered in "created" hook', () => { + mountMixin(AddToWishlist, { + propsData: { product } + }); + + expect(registerModule).toHaveBeenCalledWith(WishlistModule); + }); + + it('addToWishList method dispatches wishlist/addItem action', () => { + const mockStore = { + modules: { + wishlist: { + actions: { + addItem: jest.fn() + }, + namespaced: true + } + } + }; + + const wrapper = mountMixinWithStore(AddToWishlist, mockStore, { + propsData: { product } + }); + + (wrapper.vm as any).addToWishlist(product); + + expect(mockStore.modules.wishlist.actions.addItem).toHaveBeenCalledWith(expect.anything(), product, undefined); + }); +}); diff --git a/core/modules/wishlist/test/unit/components/IsOnWishlist.spec.ts b/core/modules/wishlist/test/unit/components/IsOnWishlist.spec.ts new file mode 100644 index 000000000..dcd3b54f6 --- /dev/null +++ b/core/modules/wishlist/test/unit/components/IsOnWishlist.spec.ts @@ -0,0 +1,63 @@ +import { mountMixin, mountMixinWithStore } from '@vue-storefront/unit-tests/utils'; +import { registerModule } from '@vue-storefront/core/lib/modules'; +import { WishlistModule } from '@vue-storefront/core/modules/wishlist'; +import { IsOnWishlist } from '@vue-storefront/core/modules/wishlist/components/IsOnWishlist'; + +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({})); +jest.mock('@vue-storefront/core/lib/modules', () => ({ registerModule: jest.fn() })); +jest.mock('@vue-storefront/core/helpers', () => ({ once: () => ({}) })); +jest.mock('@vue-storefront/core/modules/wishlist/store', () => ({})); +jest.mock('@vue-storefront/core/modules/wishlist/store/whishListPersistPlugin', () => ({})); +jest.mock('@vue-storefront/core/modules/wishlist/mixins/wishlistMountedMixin', () => ({})); + +describe('IsOnWishlist', () => { + let product; + + beforeEach(() => { + jest.clearAllMocks(); + product = { + sku: 'example_sku', + image: 'example_image' + }; + }); + + it('creates a component', () => { + const wrapper = mountMixin(IsOnWishlist, { + propsData: { product } + }); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.isVueInstance()).toBe(true); + }); + + it('component has been registered in "created" hook', () => { + mountMixin(IsOnWishlist, { + propsData: { product } + }); + + expect(registerModule).toHaveBeenCalledWith(WishlistModule); + }); + + it('isOnWishlist computed property calls wishlist/isOnWishlist getter with product from prop', () => { + const isOnWishlistGetter = jest.fn(() => true); + const mockStore = { + modules: { + wishlist: { + getters: { + isOnWishlist: () => isOnWishlistGetter + }, + namespaced: true + } + } + }; + + const wrapper = mountMixinWithStore(IsOnWishlist, mockStore, { + propsData: { product } + }); + + const isOnWishlist = (wrapper.vm as any).isOnWishlist; + + expect(isOnWishlistGetter).toHaveBeenCalledWith(product); + expect(isOnWishlist).toBe(true); + }); +}); diff --git a/core/modules/wishlist/test/unit/components/Product.spec.ts b/core/modules/wishlist/test/unit/components/Product.spec.ts new file mode 100644 index 000000000..dcd194f1b --- /dev/null +++ b/core/modules/wishlist/test/unit/components/Product.spec.ts @@ -0,0 +1,59 @@ +import { mountMixin, mountMixinWithStore } from '@vue-storefront/unit-tests/utils'; +import { WishlistProduct } from '@vue-storefront/core/modules/wishlist/components/Product'; + +jest.mock('@vue-storefront/core/modules/wishlist/mixins/wishlistMountedMixin', () => ({})); + +describe('Product', () => { + let product; + + beforeEach(() => { + jest.clearAllMocks(); + product = { + sku: 'example_sku', + image: 'example_image' + }; + }); + + it('creates a component', () => { + const wrapper = mountMixin(WishlistProduct, { + propsData: { product } + }); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.isVueInstance()).toBe(true); + }); + + it('thumbnail computed property calls getThumbnail method', () => { + const getThumbnail = jest.fn(() => 'thumbnail'); + const wrapper = mountMixin(WishlistProduct, { + propsData: { product }, + methods: { getThumbnail } + }); + + const thumbnail = (wrapper.vm as any).thumbnail; + + expect(getThumbnail).toHaveBeenCalledWith(product.image, 150, 150); + expect(thumbnail).toBe('thumbnail'); + }); + + it('removeFromWishlist method dispatches wishlist/removeItem action', () => { + const mockStore = { + modules: { + wishlist: { + actions: { + removeItem: jest.fn() + }, + namespaced: true + } + } + }; + + const wrapper = mountMixinWithStore(WishlistProduct, mockStore, { + propsData: { product } + }); + + (wrapper.vm as any).removeFromWishlist(product); + + expect(mockStore.modules.wishlist.actions.removeItem).toHaveBeenCalledWith(expect.anything(), product, undefined); + }); +}); diff --git a/core/modules/wishlist/test/unit/components/RemoveFromWishlist.spec.ts b/core/modules/wishlist/test/unit/components/RemoveFromWishlist.spec.ts new file mode 100644 index 000000000..df38ebf7e --- /dev/null +++ b/core/modules/wishlist/test/unit/components/RemoveFromWishlist.spec.ts @@ -0,0 +1,54 @@ +import { mountMixin, mountMixinWithStore } from '@vue-storefront/unit-tests/utils'; +import { registerModule } from '@vue-storefront/core/lib/modules'; +import { WishlistModule } from '@vue-storefront/core/modules/wishlist'; +import { RemoveFromWishlist } from '@vue-storefront/core/modules/wishlist/components/RemoveFromWishlist'; + +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({})); +jest.mock('@vue-storefront/core/lib/modules', () => ({ registerModule: jest.fn() })); +jest.mock('@vue-storefront/core/helpers', () => ({ once: () => ({}) })); +jest.mock('@vue-storefront/core/modules/wishlist/store', () => ({})); +jest.mock('@vue-storefront/core/modules/wishlist/store/whishListPersistPlugin', () => ({})); +jest.mock('@vue-storefront/core/modules/wishlist/mixins/wishlistMountedMixin', () => ({})); + +describe('RemoveFromWishlist', () => { + let product; + + beforeEach(() => { + jest.clearAllMocks(); + product = { + sku: 'example_sku', + image: 'example_image' + }; + }); + + it('creates a component', () => { + const wrapper = mountMixin(RemoveFromWishlist, { + propsData: { product } + }); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.isVueInstance()).toBe(true); + }); + + it('removeFromWishlist method registers component and dispatches wishlist/removeItem action', () => { + const mockStore = { + modules: { + wishlist: { + actions: { + removeItem: jest.fn() + }, + namespaced: true + } + } + }; + + const wrapper = mountMixinWithStore(RemoveFromWishlist, mockStore, { + propsData: { product } + }); + + (wrapper.vm as any).removeFromWishlist(product); + + expect(registerModule).toHaveBeenCalledWith(WishlistModule); + expect(mockStore.modules.wishlist.actions.removeItem).toHaveBeenCalledWith(expect.anything(), product, undefined); + }); +}); diff --git a/core/modules/wishlist/test/unit/components/Wishlist.spec.ts b/core/modules/wishlist/test/unit/components/Wishlist.spec.ts new file mode 100644 index 000000000..e23505b28 --- /dev/null +++ b/core/modules/wishlist/test/unit/components/Wishlist.spec.ts @@ -0,0 +1,104 @@ +import { mountMixin, mountMixinWithStore } from '@vue-storefront/unit-tests/utils'; +import { registerModule } from '@vue-storefront/core/lib/modules'; +import { WishlistModule } from '@vue-storefront/core/modules/wishlist'; +import { Wishlist } from '@vue-storefront/core/modules/wishlist/components/Wishlist'; + +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({})); +jest.mock('@vue-storefront/core/lib/modules', () => ({ registerModule: jest.fn() })); +jest.mock('@vue-storefront/core/helpers', () => ({ once: () => ({}) })); +jest.mock('@vue-storefront/core/modules/wishlist/store', () => ({})); +jest.mock('@vue-storefront/core/modules/wishlist/store/whishListPersistPlugin', () => ({})); +jest.mock('@vue-storefront/core/modules/wishlist/mixins/wishlistMountedMixin', () => ({})); + +describe('Wishlist', () => { + let product; + + beforeEach(() => { + jest.clearAllMocks(); + product = { + sku: 'example_sku', + image: 'example_image' + }; + }); + + it('creates a component', () => { + const wrapper = mountMixin(Wishlist, { + propsData: { product } + }); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.isVueInstance()).toBe(true); + }); + + it('component has been registered in "created" hook', () => { + mountMixin(Wishlist, { + propsData: { product } + }); + + expect(registerModule).toHaveBeenCalledWith(WishlistModule); + }); + + it('isWishlistOpen computed property returns ui/wishlist state', () => { + const mockStore = { + modules: { + ui: { + state: { + wishlist: true + }, + namespaced: true + } + } + }; + + const wrapper = mountMixinWithStore(Wishlist, mockStore, { + propsData: { product } + }); + + const result = (wrapper.vm as any).isWishlistOpen; + + expect(result).toBe(true); + }); + + it('productsInWishlist computed property returns wishlist/items state', () => { + const wishlistItems = [{ sku: 1 }, { sku: 2 }, { sku: 3 }]; + const mockStore = { + modules: { + wishlist: { + state: { + items: wishlistItems + }, + namespaced: true + } + } + }; + + const wrapper = mountMixinWithStore(Wishlist, mockStore, { + propsData: { product } + }); + + const result = (wrapper.vm as any).productsInWishlist; + + expect(result).toBe(wishlistItems); + }); + + it('closeWishlist method dispatches ui/toggleWishlist action', () => { + const mockStore = { + modules: { + ui: { + actions: { + toggleWishlist: jest.fn() + }, + namespaced: true + } + } + }; + + const wrapper = mountMixinWithStore(Wishlist, mockStore, { + propsData: { product } + }); + + (wrapper.vm as any).closeWishlist(); + + expect(mockStore.modules.ui.actions.toggleWishlist).toHaveBeenCalled(); + }); +}); diff --git a/core/modules/wishlist/test/unit/components/WishlistButton.spec.ts b/core/modules/wishlist/test/unit/components/WishlistButton.spec.ts new file mode 100644 index 000000000..8ceb929a4 --- /dev/null +++ b/core/modules/wishlist/test/unit/components/WishlistButton.spec.ts @@ -0,0 +1,57 @@ +import { mountMixin, mountMixinWithStore } from '@vue-storefront/unit-tests/utils'; +import { WishlistButton } from '@vue-storefront/core/modules/wishlist/components/WishlistButton'; + +jest.mock('@vue-storefront/core/modules/wishlist/mixins/wishlistMountedMixin', () => ({})); + +describe('WishlistButton', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a component', () => { + const wrapper = mountMixin(WishlistButton); + + expect(wrapper.exists()).toBe(true); + expect(wrapper.isVueInstance()).toBe(true); + }); + + it('getWishlistItemsCount computed property calls wishlist/getWishlistItemsCount getter', () => { + const getWishlistItemsCountGetter = jest.fn(() => 42); + const mockStore = { + modules: { + wishlist: { + getters: { + getWishlistItemsCount: getWishlistItemsCountGetter + }, + namespaced: true + } + } + }; + + const wrapper = mountMixinWithStore(WishlistButton, mockStore); + + const getWishlistItemsCount = (wrapper.vm as any).getWishlistItemsCount; + + expect(getWishlistItemsCountGetter).toHaveBeenCalled(); + expect(getWishlistItemsCount).toBe(42); + }); + + it('toggleWishlist method dispatches ui/toggleWishlist action', () => { + const mockStore = { + modules: { + ui: { + actions: { + toggleWishlist: jest.fn() + }, + namespaced: true + } + } + }; + + const wrapper = mountMixinWithStore(WishlistButton, mockStore); + + (wrapper.vm as any).toggleWishlist(); + + expect(mockStore.modules.ui.actions.toggleWishlist).toHaveBeenCalled(); + }); +}); diff --git a/core/modules/wishlist/test/unit/mixins/wishlistMountedMixin.spec.ts b/core/modules/wishlist/test/unit/mixins/wishlistMountedMixin.spec.ts new file mode 100644 index 000000000..b966e4159 --- /dev/null +++ b/core/modules/wishlist/test/unit/mixins/wishlistMountedMixin.spec.ts @@ -0,0 +1,25 @@ +import { mountMixinWithStore } from '@vue-storefront/unit-tests/utils'; +import wishlistMountedMixin from '@vue-storefront/core/modules/wishlist/mixins/wishlistMountedMixin'; + +describe('wishlistMountedMixin', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('dispatches wishlist/load action on mount', () => { + const mockStore = { + modules: { + wishlist: { + actions: { + load: jest.fn() + }, + namespaced: true + } + } + }; + + mountMixinWithStore(wishlistMountedMixin, mockStore); + + expect(mockStore.modules.wishlist.actions.load).toHaveBeenCalled(); + }); +}); diff --git a/core/modules/wishlist/test/unit/store/actions.spec.ts b/core/modules/wishlist/test/unit/store/actions.spec.ts new file mode 100644 index 000000000..f6eb698a3 --- /dev/null +++ b/core/modules/wishlist/test/unit/store/actions.spec.ts @@ -0,0 +1,130 @@ +import * as types from '@vue-storefront/core/modules/wishlist/store/mutation-types'; +import wishlistActions from '@vue-storefront/core/modules/wishlist/store/actions'; +import { StorageManager } from '@vue-storefront/core/lib/storage-manager'; + +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ + StorageManager: { + get: jest.fn() + } +})); + +describe('Wishlist actions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('clear', () => { + it('should delete all items', () => { + const mockContext = { + commit: jest.fn() + }; + + (wishlistActions as any).clear(mockContext); + + expect(mockContext.commit).toHaveBeenCalledWith(types.WISH_DEL_ALL_ITEMS, []); + }); + }); + + describe('load', () => { + let wishlist; + + beforeEach(() => { + wishlist = [{ sku: 1 }, { sku: 2 }, { sku: 3 }]; + }); + + it('should not load wishlist if it is already loaded', () => { + const mockContext = { + commit: jest.fn(), + dispatch: jest.fn(), + getters: { + isWishlistLoaded: true + } + }; + + (wishlistActions as any).load(mockContext); + + expect(mockContext.commit).not.toHaveBeenCalled(); + expect(mockContext.dispatch).not.toHaveBeenCalled(); + }); + + it('should load wishlist if it is not loaded', async () => { + const mockContext = { + commit: jest.fn(), + dispatch: jest.fn(() => { + return new Promise(resolve => resolve(wishlist)); + }), + getters: { + isWishlistLoaded: false + } + }; + + await (wishlistActions as any).load(mockContext); + + expect(mockContext.commit).toHaveBeenCalledTimes(2); + expect(mockContext.commit).toHaveBeenNthCalledWith(1, types.SET_WISHLIST_LOADED); + expect(mockContext.commit).toHaveBeenNthCalledWith(2, types.WISH_LOAD_WISH, wishlist); + expect(mockContext.dispatch).toHaveBeenCalledWith('loadFromCache'); + }); + + it('should load wishlist with "force" argument even if it is already loaded', async () => { + const mockContext = { + commit: jest.fn(), + dispatch: jest.fn(() => { + return new Promise(resolve => resolve(wishlist)); + }), + getters: { + isWishlistLoaded: true + } + }; + + await (wishlistActions as any).load(mockContext, true); + + expect(mockContext.commit).toHaveBeenCalledTimes(2); + expect(mockContext.commit).toHaveBeenNthCalledWith(1, types.SET_WISHLIST_LOADED); + expect(mockContext.commit).toHaveBeenNthCalledWith(2, types.WISH_LOAD_WISH, wishlist); + expect(mockContext.dispatch).toHaveBeenCalledWith('loadFromCache'); + }); + }); + + describe('loadFromCache', () => { + it('should load wishlist from cache', () => { + const mockGetItem = jest.fn(() => ({})); + + (StorageManager.get as jest.Mock).mockImplementation(() => ({ + getItem: mockGetItem + })); + + const wishlistStorage = (wishlistActions as any).loadFromCache(); + + expect(StorageManager.get).toHaveBeenCalledWith('wishlist'); + expect(mockGetItem).toHaveBeenCalledWith('current-wishlist'); + expect(wishlistStorage).toEqual({}); + }); + }); + + describe('addItem', () => { + it('should add product to wishlist', () => { + const product = { sku: 1 }; + const mockContext = { + commit: jest.fn() + }; + + (wishlistActions as any).addItem(mockContext, product); + + expect(mockContext.commit).toHaveBeenCalledWith(types.WISH_ADD_ITEM, { product }); + }); + }); + + describe('removeItem', () => { + it('should remove product from wishlist', () => { + const product = { sku: 1 }; + const mockContext = { + commit: jest.fn() + }; + + (wishlistActions as any).removeItem(mockContext, product); + + expect(mockContext.commit).toHaveBeenCalledWith(types.WISH_DEL_ITEM, { product }); + }); + }); +}); diff --git a/core/modules/wishlist/test/unit/store/getters.spec.ts b/core/modules/wishlist/test/unit/store/getters.spec.ts new file mode 100644 index 000000000..9bf2512ea --- /dev/null +++ b/core/modules/wishlist/test/unit/store/getters.spec.ts @@ -0,0 +1,37 @@ +import wishlistGetters from '@vue-storefront/core/modules/wishlist/store/getters'; + +describe('Wishlist getters', () => { + it('should inform if given product is on wishlist', () => { + const mockState = { + items: [ + { sku: 1 }, { sku: 2 }, { sku: 3 } + ] + }; + + const productExists = (wishlistGetters as any).isOnWishlist(mockState)({ sku: 1 }); + const productDoesNotExist = (wishlistGetters as any).isOnWishlist(mockState)({ sku: 123 }); + + expect(productExists).toBe(true); + expect(productDoesNotExist).toBe(false); + }); + + it('should inform if wishlist is loaded', () => { + const wishlistIsLoaded = (wishlistGetters as any).isWishlistLoaded({ loaded: true }); + const wishlistIsNotLoaded = (wishlistGetters as any).isWishlistLoaded({ loaded: false }); + + expect(wishlistIsLoaded).toBe(true); + expect(wishlistIsNotLoaded).toBe(false); + }); + + it('should return number of products in wishlist', () => { + const mockState = { + items: [ + { sku: 1 }, { sku: 2 }, { sku: 3 } + ] + }; + + const numberOfProducts = (wishlistGetters as any).getWishlistItemsCount(mockState); + + expect(numberOfProducts).toBe(mockState.items.length); + }); +}); diff --git a/core/modules/wishlist/test/unit/store/mutations.spec.ts b/core/modules/wishlist/test/unit/store/mutations.spec.ts new file mode 100644 index 000000000..bba28e6b4 --- /dev/null +++ b/core/modules/wishlist/test/unit/store/mutations.spec.ts @@ -0,0 +1,147 @@ +import * as types from '../../../store/mutation-types'; +import wishlistMutations from '../../../store/mutations' + +describe('Wishlist mutations', () => { + let product1; + let product2; + let product3; + + beforeEach(() => { + product1 = { + sku: 'example-product-id1', + qty: 123 + }; + + product2 = { + sku: 'example-product-id2', + qty: 456 + }; + + product3 = { + sku: 'example-product-id3', + qty: 789 + }; + }); + + describe('WISH_ADD_ITEM', () => { + it('should add exactly one product to wishlist if it does not exist there', () => { + const mockState = { + items: [] + }; + + const expectedState = { + items: [{ ...product1, qty: 1 }] + }; + + (wishlistMutations as any)[types.WISH_ADD_ITEM](mockState, { product: product1 }); + + expect(mockState).toEqual(expectedState); + }); + + it('should not add product to wishlist if it exists there', () => { + const mockState = { + items: [{ ...product1 }] + }; + + const expectedState = { + items: [{ ...product1 }] + }; + + (wishlistMutations as any)[types.WISH_ADD_ITEM](mockState, { product: product1 }); + + expect(mockState).toEqual(expectedState); + }); + }); + + describe('WISH_DEL_ITEM', () => { + it('should remove existing product from wishlist', () => { + const mockState = { + items: [{ ...product1 }, { ...product2 }, { ...product3 }] + }; + + const expectedState = { + items: [{ ...product1 }, { ...product2 }] + }; + + (wishlistMutations as any)[types.WISH_DEL_ITEM](mockState, { product: product3 }); + + expect(mockState).toEqual(expectedState); + }); + + it('should not modify wishlist if product does not exist there', () => { + const mockState = { + items: [{ ...product1 }, { ...product2 }] + }; + + const expectedState = { + items: [{ ...product1 }, { ...product2 }] + }; + + (wishlistMutations as any)[types.WISH_DEL_ITEM](mockState, { product: product3 }); + + expect(mockState).toEqual(expectedState); + }); + }); + + describe('WISH_LOAD_WISH', () => { + it('should init wishlist', () => { + const mockState = { + items: [{ ...product1 }] + }; + + const expectedState = { + items: [{ ...product2 }, { ...product3 }] + }; + + (wishlistMutations as any)[types.WISH_LOAD_WISH](mockState, [{ ...product2 }, { ...product3 }]); + + expect(mockState).toEqual(expectedState); + }); + + it('should init wishlist with empty array if loaded wishlist is falsy', () => { + const mockState = { + items: [{ ...product1 }] + }; + + const expectedState = { + items: [] + }; + + (wishlistMutations as any)[types.WISH_LOAD_WISH](mockState, null); + + expect(mockState).toEqual(expectedState); + }); + }); + + describe('WISH_DEL_ALL_ITEMS', () => { + it('should delete all products from wishlist', () => { + const mockState = { + items: [{ ...product1 }, { ...product2 }, { ...product3 }] + }; + + const expectedState = { + items: [] + }; + + (wishlistMutations as any)[types.WISH_DEL_ALL_ITEMS](mockState); + + expect(mockState).toEqual(expectedState); + }); + }); + + describe('SET_WISHLIST_LOADED', () => { + it('should set loaded state for wishlist', () => { + const mockState = { + loaded: false + }; + + const expectedState = { + loaded: true + }; + + (wishlistMutations as any)[types.SET_WISHLIST_LOADED](mockState); + + expect(mockState).toEqual(expectedState); + }); + }); +}); diff --git a/core/modules/wishlist/test/unit/store/whishListPersistPlugin.spec.ts b/core/modules/wishlist/test/unit/store/whishListPersistPlugin.spec.ts new file mode 100644 index 000000000..bc69e2365 --- /dev/null +++ b/core/modules/wishlist/test/unit/store/whishListPersistPlugin.spec.ts @@ -0,0 +1,64 @@ +import * as types from '@vue-storefront/core/modules/wishlist/store/mutation-types'; +import whishListPersistPlugin from '@vue-storefront/core/modules/wishlist/store/whishListPersistPlugin'; +import { StorageManager } from '@vue-storefront/core/lib/storage-manager'; + +jest.mock('@vue-storefront/core/lib/storage-manager', () => ({ + StorageManager: { + get: jest.fn() + } +})); + +describe('whishListPersistPlugin', () => { + let mockSetItem; + let mockState; + + beforeEach(() => { + mockSetItem = jest.fn(); + + (StorageManager.get as jest.Mock).mockImplementation(() => ({ + setItem: mockSetItem + })); + + mockState = { + wishlist: { + items: [ + { sku: 1 }, { sku: 2 }, { sku: 3 } + ] + } + }; + + jest.clearAllMocks(); + }); + + it('should store wishlist in cache for supported mutations', () => { + const mutations = [ + { type: `wishlist/${types.WISH_ADD_ITEM}` }, + { type: `wishlist/${types.WISH_DEL_ITEM}` }, + { type: `wishlist/${types.WISH_DEL_ALL_ITEMS}` } + ]; + + mutations.forEach(mutation => whishListPersistPlugin(mutation, mockState)); + + expect(StorageManager.get).toHaveBeenCalledTimes(mutations.length); + expect(StorageManager.get).toHaveBeenCalledWith('wishlist'); + expect(mockSetItem).toHaveBeenCalledTimes(mutations.length); + expect(mockSetItem).toHaveBeenCalledWith('current-wishlist', mockState.wishlist.items); + }); + + it('should not store wishlist in cache for unsupported mutations', () => { + const mutations = [ + { type: 'a/b/c' }, + { type: types.WISH_ADD_ITEM }, + { type: types.WISH_DEL_ITEM }, + { type: types.WISH_DEL_ALL_ITEMS }, + { type: `wishlist/${types.WISH_LOAD_WISH}` }, + { type: `wishlist/${types.SET_WISHLIST_LOADED}` } + ]; + + mutations.forEach(mutation => whishListPersistPlugin(mutation, mockState)); + + expect(StorageManager.get).toHaveBeenCalledTimes(mutations.length); + expect(StorageManager.get).toHaveBeenCalledWith('wishlist'); + expect(mockSetItem).not.toHaveBeenCalled(); + }); +}); diff --git a/core/package.json b/core/package.json index bef4426cf..ca87dcdc2 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@vue-storefront/core", - "version": "1.10.0", + "version": "1.11.0", "description": "Vue Storefront Core", "license": "MIT", "main": "app.js", @@ -9,8 +9,10 @@ }, "dependencies": { "bodybuilder": "2.2.13", + "compression": "^1.7.4", "config": "^1.30.0", "express": "^4.14.0", + "html-minifier": "^4.0.0", "lean-he": "^2.0.0", "localforage": "^1.7.2", "lodash-es": "^4.17.10", diff --git a/core/pages/Category.js b/core/pages/Category.js index bed87e0c1..882886461 100644 --- a/core/pages/Category.js +++ b/core/pages/Category.js @@ -28,6 +28,7 @@ export default { }, computed: { ...mapGetters('category', ['getCurrentCategory', 'getCurrentCategoryProductQuery', 'getAllCategoryFilters', 'getCategoryBreadcrumbs', 'getCurrentCategoryPath']), + ...mapGetters('tax', ['getIsUserGroupedTaxActive']), products () { return this.$store.getters['product/list'] }, @@ -127,7 +128,7 @@ export default { beforeMount () { this.$bus.$on('filter-changed-category', this.onFilterChanged) this.$bus.$on('list-change-sort', this.onSortOrderChanged) - if (config.usePriceTiers) { + if (config.usePriceTiers || this.getIsUserGroupedTaxActive) { this.$bus.$on('user-after-loggedin', this.onUserPricesRefreshed) this.$bus.$on('user-after-logout', this.onUserPricesRefreshed) } @@ -135,7 +136,7 @@ export default { beforeDestroy () { this.$bus.$off('list-change-sort', this.onSortOrderChanged) this.$bus.$off('filter-changed-category', this.onFilterChanged) - if (config.usePriceTiers) { + if (config.usePriceTiers || this.getIsUserGroupedTaxActive) { this.$bus.$off('user-after-loggedin', this.onUserPricesRefreshed) this.$bus.$off('user-after-logout', this.onUserPricesRefreshed) } diff --git a/core/pages/Checkout.js b/core/pages/Checkout.js index e7fb12230..19040a24b 100644 --- a/core/pages/Checkout.js +++ b/core/pages/Checkout.js @@ -3,7 +3,7 @@ import i18n from '@vue-storefront/i18n' import config from 'config' 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 { isServer } from '@vue-storefront/core/helpers' @@ -45,8 +45,9 @@ export default { }) }, beforeMount () { + this.$store.dispatch('checkout/load') this.$store.dispatch('checkout/setModifiedAt', Date.now()) - // TO-DO: Use one event with name as apram + // TODO: Use one event with name as apram this.$bus.$on('cart-after-update', this.onCartAfterUpdate) this.$bus.$on('cart-after-delete', this.onCartAfterUpdate) this.$bus.$on('checkout-after-personalDetails', this.onAfterPersonalDetails) @@ -71,7 +72,7 @@ export default { for (let product of this.$store.state.cart.cartItems) { // check the results of online stock check if (product.onlineStockCheckid) { checkPromises.push(new Promise((resolve, reject) => { - Vue.prototype.$db.syncTaskCollection.getItem(product.onlineStockCheckid, (err, item) => { + StorageManager.get('syncTasks').getItem(product.onlineStockCheckid, (err, item) => { if (err || !item) { if (err) Logger.error(err)() resolve(null) @@ -136,6 +137,7 @@ export default { this.shippingMethod = payload }, onBeforeShippingMethods (country) { + this.$store.dispatch('checkout/updatePropValue', ['country', country]) this.$store.dispatch('cart/syncTotals', { forceServerSync: true }) this.$forceUpdate() }, @@ -261,7 +263,7 @@ export default { prepareOrder () { this.order = { user_id: this.$store.state.user.current ? this.$store.state.user.current.id.toString() : '', - cart_id: this.$store.state.cart.cartServerToken ? this.$store.state.cart.cartServerToken : '', + cart_id: this.$store.state.cart.cartServerToken ? this.$store.state.cart.cartServerToken.toString() : '', products: this.$store.state.cart.cartItems, addressInformation: { billingAddress: { diff --git a/core/pages/MyAccount.js b/core/pages/MyAccount.js index 73808509c..d0bd4d446 100644 --- a/core/pages/MyAccount.js +++ b/core/pages/MyAccount.js @@ -2,6 +2,7 @@ import i18n from '@vue-storefront/i18n' import Composite from '@vue-storefront/core/mixins/composite' import { Logger } from '@vue-storefront/core/lib/logger' +import { currentStoreView, localizedRoute } from '@vue-storefront/core/lib/multistore' export default { name: 'MyAccount', @@ -22,6 +23,13 @@ export default { this.$bus.$on('myAccount-before-updateUser', this.onBeforeUpdateUser) this.$bus.$on('myAccount-before-changePassword', this.onBeforeChangePassword) }, + async mounted () { + await this.$store.dispatch('user/startSession') + if (!this.$store.getters['user/isLoggedIn']) { + localStorage.setItem('redirect', this.$route.path) + this.$router.push(localizedRoute('/', currentStoreView().storeCode)) + } + }, destroyed () { this.$bus.$off('myAccount-before-updateUser', this.onBeforeUpdateUser) this.$bus.$off('myAccount-before-changePassword', this.onBeforeChangePassword) diff --git a/core/pages/PageNotFound.js b/core/pages/PageNotFound.js index f84e0e765..808df37a5 100644 --- a/core/pages/PageNotFound.js +++ b/core/pages/PageNotFound.js @@ -9,7 +9,10 @@ export default { mixins: [Composite], async asyncData ({ store, route, context }) { // this is for SSR purposes to prefetch data Logger.log('Entering asyncData for PageNotFound ' + new Date())() - if (context) context.output.cacheTags.add(`page-not-found`) + if (context) { + context.output.cacheTags.add(`page-not-found`) + context.server.response.statusCode = 404 + } let ourBestsellersQuery = prepareQuery({ queryConfig: 'bestSellers' }) const response = await store.dispatch('product/list', { query: ourBestsellersQuery, diff --git a/core/pages/Product.js b/core/pages/Product.js index a5b85e916..2601e9431 100644 --- a/core/pages/Product.js +++ b/core/pages/Product.js @@ -1,20 +1,20 @@ import { mapGetters } from 'vuex' import config from 'config' -import store from '@vue-storefront/core/store' import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' import { htmlDecode } from '@vue-storefront/core/filters' import { currentStoreView, localizedRoute } from '@vue-storefront/core/lib/multistore' import { CompareProduct } from '@vue-storefront/core/modules/compare/components/Product.ts' import { AddToCompare } from '@vue-storefront/core/modules/compare/components/AddToCompare.ts' -import { isOptionAvailableAsync } from '@vue-storefront/core/modules/catalog/helpers/index' +import { ProductOption } from '@vue-storefront/core/modules/catalog/components/ProductOption.ts' import omit from 'lodash-es/omit' import Composite from '@vue-storefront/core/mixins/composite' import { Logger } from '@vue-storefront/core/lib/logger' +import { formatProductLink } from '@vue-storefront/core/modules/url/helpers' export default { name: 'Product', - mixins: [Composite, AddToCompare, CompareProduct], + mixins: [Composite, AddToCompare, CompareProduct, ProductOption], data () { return { loading: false @@ -22,16 +22,17 @@ export default { }, computed: { ...mapGetters({ - product: 'product/productCurrent', - originalProduct: 'product/productOriginal', - parentProduct: 'product/productParent', - attributesByCode: 'attribute/attributeListByCode', - attributesById: 'attribute/attributeListById', - breadcrumbs: 'product/breadcrumbs', - configuration: 'product/currentConfiguration', - options: 'product/currentOptions', + product: 'product/getCurrentProduct', + originalProduct: 'product/getOriginalProduct', + parentProduct: 'product/getParentProduct', + attributesByCode: 'attribute/getAttributeListByCode', + attributesById: 'attribute/getAttributeListById', + breadcrumbs: 'category-next/getBreadcrumbs', + configuration: 'product/getCurrentProductConfiguration', + options: 'product/getCurrentProductOptions', category: 'category/getCurrentCategory', - gallery: 'product/productGallery' + gallery: 'product/getProductGallery', + isUserGroupedTaxActive: 'tax/getIsUserGroupedTaxActive' }), productName () { return this.product ? this.product.name : '' @@ -61,30 +62,31 @@ export default { asyncData ({ store, route, context }) { // this is for SSR purposes to prefetch data EventBus.$emit('product-before-load', { store: store, route: route }) 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 }) + return store.dispatch('product/loadProduct', { parentSku: route.params.parentSku, childSku: route && route.params && route.params.childSku ? route.params.childSku : null }) }, beforeRouteUpdate (to, from, next) { this.validateRoute(to) // TODO: remove because client-entry.ts is executing `asyncData` anyway next() }, + // Move busses to mixin which is directly imported in Project.vue beforeDestroy () { this.$bus.$off('product-after-removevariant') this.$bus.$off('filter-changed-product') this.$bus.$off('product-after-priceupdate', this.onAfterPriceUpdate) this.$bus.$off('product-after-customoptions') this.$bus.$off('product-after-bundleoptions') - if (config.usePriceTiers) { + if (config.usePriceTiers || this.isUserGroupedTaxActive) { this.$bus.$off('user-after-loggedin', this.onUserPricesRefreshed) this.$bus.$off('user-after-logout', this.onUserPricesRefreshed) } }, beforeMount () { this.$bus.$on('product-after-removevariant', this.onAfterVariantChanged) - this.$bus.$on('product-after-priceupdate', this.onAfterPriceUpdate) - this.$bus.$on('filter-changed-product', this.onAfterFilterChanged) - this.$bus.$on('product-after-customoptions', this.onAfterCustomOptionsChanged) - this.$bus.$on('product-after-bundleoptions', this.onAfterBundleOptionsChanged) - if (config.usePriceTiers) { + this.$bus.$on('product-after-priceupdate', this.onAfterPriceUpdate) // moved to catalog module + this.$bus.$on('filter-changed-product', this.onAfterFilterChanged) // moved to catalog module + this.$bus.$on('product-after-customoptions', this.onAfterCustomOptionsChanged) // moved to catalog module + this.$bus.$on('product-after-bundleoptions', this.onAfterBundleOptionsChanged) // moved to catalog module + if (config.usePriceTiers || this.isUserGroupedTaxActive) { // moved to catalog module this.$bus.$on('user-after-loggedin', this.onUserPricesRefreshed) this.$bus.$on('user-after-logout', this.onUserPricesRefreshed) } @@ -95,7 +97,7 @@ export default { validateRoute (route = this.$route) { if (!this.loading) { this.loading = true - this.$store.dispatch('product/fetchAsync', { parentSku: route.params.parentSku, childSku: route && route.params && route.params.childSku ? route.params.childSku : null }).then(res => { + this.$store.dispatch('product/loadProduct', { 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() @@ -124,11 +126,6 @@ export default { // Method renamed to 'removeFromCompare(product)', product is an Object CompareProduct.methods.removeFromCompare.call(this, this.product) }, - isOptionAvailable (option) { // check if the option is available - let currentConfig = Object.assign({}, this.configuration) - currentConfig[option.attribute_code] = option - return isOptionAvailableAsync(this.$store, { product: this.product, configuration: currentConfig }) - }, onAfterCustomOptionsChanged (payload) { let priceDelta = 0 let priceDeltaInclTax = 0 @@ -140,12 +137,12 @@ export default { } if (optionValue.price_type === 'percent' && optionValue.price !== 0) { priceDelta += ((optionValue.price / 100) * this.originalProduct.price) - priceDeltaInclTax += ((optionValue.price / 100) * this.originalProduct.priceInclTax) + priceDeltaInclTax += ((optionValue.price / 100) * this.originalProduct.price_incl_tax) } } } this.product.price = this.originalProduct.price + priceDelta - this.product.priceInclTax = this.originalProduct.priceInclTax + priceDeltaInclTax + this.product.price_incl_tax = this.originalProduct.price_incl_tax + priceDeltaInclTax }, onAfterBundleOptionsChanged (payload) { let priceDelta = 0 @@ -153,18 +150,19 @@ export default { for (const optionValue of Object.values(payload.optionValues)) { if (typeof optionValue.value.product !== 'undefined' && parseInt(optionValue.qty) >= 0) { priceDelta += optionValue.value.product.price * parseInt(optionValue.qty) - priceDeltaInclTax += optionValue.value.product.priceInclTax * parseInt(optionValue.qty) + priceDeltaInclTax += optionValue.value.product.price_incl_tax * parseInt(optionValue.qty) } } if (priceDelta > 0) { this.product.price = priceDelta - this.product.priceInclTax = priceDeltaInclTax + this.product.price_incl_tax = priceDeltaInclTax } }, onStateCheck () { if (this.parentProduct && this.parentProduct.id !== this.product.id) { Logger.log('Redirecting to parent, configurable product', this.parentProduct.sku)() - this.$router.replace({ name: 'product', params: { parentSku: this.parentProduct.sku, childSku: this.product.sku, slug: this.parentProduct.slug } }) + const parentUrl = formatProductLink(this.parentProduct, currentStoreView().storeCode) + this.$router.replace(parentUrl) } }, onAfterPriceUpdate (product) { @@ -178,16 +176,17 @@ export default { } }, onAfterVariantChanged (payload) { + this.$store.dispatch('product/setProductGallery', { product: this.product }) this.$forceUpdate() }, onAfterFilterChanged (filterOption) { this.$bus.$emit('product-before-configure', { filterOption: filterOption, configuration: this.configuration }) const prevOption = this.configuration[filterOption.attribute_code] - this.configuration[filterOption.attribute_code] = filterOption + let changedConfig = Object.assign({}, this.configuration, {[filterOption.attribute_code]: filterOption}) this.$forceUpdate() // this is to update the available options regarding current selection this.$store.dispatch('product/configure', { product: this.product, - configuration: this.configuration, + configuration: changedConfig, selectDefaultVariant: true, fallbackToDefaultWhenNoAvailable: false, setProductErorrs: true diff --git a/core/scripts/entry.ts b/core/scripts/entry.ts deleted file mode 100644 index 75a8f026e..000000000 --- a/core/scripts/entry.ts +++ /dev/null @@ -1,3 +0,0 @@ -import server from './server.js'; - -export default server; diff --git a/core/scripts/generate.ts b/core/scripts/generate.ts new file mode 100644 index 000000000..9f1ffa859 --- /dev/null +++ b/core/scripts/generate.ts @@ -0,0 +1,186 @@ +import generator from './utils/page-generator' +import { path as rootPath } from 'app-root-path' +import * as path from 'path' +import * as ssr from './utils/ssr-renderer' +import config from 'config' +import themeRoot from '../build/theme-path' +import { search } from './utils/catalog-client' +import bodybuilder from 'bodybuilder' +import program from 'commander' +const resolve = file => path.resolve(rootPath, file) + +// eslint-disable-next-line @typescript-eslint/no-use-before-define +const { renderer, templatesCache, destPath } = _prepareRenderer(); + +async function _renderItems (itemsSource, pageFrom, pageSize, useRelativePaths = false, urlToFileNameMapper = (destPath, item) => { + return (path.join(destPath, item._source.url_path)) +}) { + let recordsProcessed = 0 + let results = null + do { + results = await itemsSource(pageFrom, pageSize) + console.log(`Processing records - pageSize: ${pageSize} from: ${pageFrom}`) + if (results.hits && results.hits.hits.length > 0) { + results.hits.hits.forEach(async item => { + console.log(`Generating static page for ${item._source.url_path} - ${item._source.name}`) + const urlToRender = item._source.url_path + const res = { redirect: (url) => {} } + const req = { url: urlToRender } + const context = ssr.initSSRRequestContext(null, req, res, config) + try { + let output = await renderer.renderToString(context) + const outputFilename = urlToFileNameMapper(destPath, item) + output = ssr.applyAdvancedOutputProcessing(context, output, templatesCache, true, useRelativePaths, destPath, outputFilename); + generator.saveRenderedPage(outputFilename, output) + } catch (err) { + console.error(`Error rendering item: ${item._source.name}: ${err}`) + } + }); + recordsProcessed += results.hits.hits.length + } else { + console.log(`Done! Total number of items processed: ${recordsProcessed}`) + } + pageFrom = pageFrom + pageSize + } while (results.hits && results.hits.hits.length > 0) +} + +// TODO: all, prepare, clear commands + relative paths as an option +const _cmdGenerateProducts = async (cmd) => { + const getProductsPage = (from, size) => search({ + size: size, + from: from, + sort: 'id:desc', + type: 'product', + searchQuery: bodybuilder().filter('terms', 'visibility', [2, 3, 4]).andFilter('term', 'status', 1).build() + }, config, config /* TODO: add support for different storeviews */) + let pageFrom = parseInt(cmd.from) + let pageSize = parseInt(cmd.size) + + await _renderItems(getProductsPage, pageFrom, pageSize, cmd.relative) +} +program + .command('products') + .option('-r|--relative ', 'use relative paths', false) + .option('-f|--from ', 'from - starting record', 0) + .option('-s|--size ', 'size - batch size', 20) + .action(_cmdGenerateProducts) + +const _cmdGenerateCategories = async (cmd) => { + const getCategoriesPage = (from, size) => search({ + size: size, + from: from, + sort: 'id:desc', + type: 'category', + searchQuery: bodybuilder().filter('term', 'is_active', true).build() + }, config, config /* TODO: add support for different storeviews */) + let pageFrom = parseInt(cmd.from) + let pageSize = parseInt(cmd.size) + + await _renderItems(getCategoriesPage, pageFrom, pageSize, cmd.relative, (destPath, item) => { + return (path.join(destPath, `${item._source.url_path}/index.html`)) + }) +} +program + .command('categories') + .option('-r|--relative ', 'use relative paths', false) + .option('-f|--from ', 'from - starting record', 0) + .option('-s|--size ', 'size - batch size', 20) + .action(_cmdGenerateCategories) + +const _cmdGenerateCms = async (cmd) => { + const getCmsPage = (from, size) => search({ + size: size, + from: from, + sort: 'id:desc', + type: 'cms_page', + searchQuery: bodybuilder().build() + }, config, config /* TODO: add support for different storeviews */).then(results => { + if (results.hits && results.hits.hits.length > 0) { + results.hits.hits.map(page => { page._source.url_path = `/i/${page._source.identifier}` }) + } + return results + }) + let pageFrom = parseInt(cmd.from) + let pageSize = parseInt(cmd.size) + + await _renderItems(getCmsPage, pageFrom, pageSize, cmd.relative) +} +program + .command('cms') + .option('-r|--relative ', 'use relative paths', false) + .option('-f|--from ', 'from - starting record', 0) + .option('-s|--size ', 'size - batch size', 20) + .action(_cmdGenerateCms) + +const _cmdPrepare = async (cmd) => { + await generator.clearAll(destPath); + await generator.saveScripts(resolve(''), destPath); + await generator.saveSW(resolve(''), destPath); + await generator.saveAssets(themeRoot, destPath); +} +program + .command('prepare') + .action(_cmdPrepare) + +const _cmdAll = async (cmd) => { + await _cmdPrepare(cmd) + // render home page + await _renderItems(async (from, to) => { + if (from === 0) { + return { + hits: { + hits: [ + { + _source: { + name: 'Home page', + output_file_name: 'index.html', + url_path: '/' // to render home page + } + }, + { + _source: { + name: 'Page not found', + output_file_name: 'page-not-found', + url_path: '/page-not-found' // to render home page + } + } + ] + } + } + } else { + return { hits: null } + } + }, 0, 50, cmd.relative, (destPath, item) => { + return path.join(destPath, item._source.output_file_name) + }) + await _cmdGenerateCategories(cmd) + await _cmdGenerateProducts(cmd) + await _cmdGenerateCms(cmd) +} +program + .command('all') + .option('-r|--relative ', 'use relative paths', false) + .option('-f|--from ', 'from - starting record', 0) + .option('-s|--size ', 'size - batch size', 20) + .action(_cmdAll) + +function _prepareRenderer () { + const compileOptions = { + escape: /{{([^{][\s\S]+?[^}])}}/g, + interpolate: /{{{([\s\S]+?)}}}/g + }; + const templatesCache = ssr.initTemplatesCache(config, compileOptions); + // In production: create server renderer using server bundle and index HTML + // template from real fs. + // The server bundle is generated by vue-ssr-webpack-plugin. + const clientManifest = require(resolve('dist/vue-ssr-client-manifest.json')); + const bundle = require(resolve('dist/vue-ssr-bundle.json')); + // src/index.template.html is processed by html-webpack-plugin to inject + // build assets and output as dist/index.html. + // TODO: Add dynamic templates loading from (config based?) list + const renderer = ssr.createRenderer(bundle, clientManifest); + const destPath = resolve(config.staticPages.destPath); + return { renderer, templatesCache, destPath }; +} + +program.parse(process.argv) diff --git a/core/scripts/installer.js b/core/scripts/installer.js index 4ce622cda..f8dc04fb0 100644 --- a/core/scripts/installer.js +++ b/core/scripts/installer.js @@ -429,7 +429,7 @@ class Storefront extends Abstract { config.orders.endpoint = `${backendPath}/api/order` config.products.endpoint = `${backendPath}/api/product` config.users.endpoint = `${backendPath}/api/user` - config.users.history_endpoint = `${backendPath}/api/user/order-history?token={{token}}` + 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.changePassword_endpoint = `${backendPath}/api/user/change-password?token={{token}}` config.users.login_endpoint = `${backendPath}/api/user/login` diff --git a/core/scripts/server.js b/core/scripts/server.ts similarity index 63% rename from core/scripts/server.js rename to core/scripts/server.ts index 49f17d32b..361e74530 100755 --- a/core/scripts/server.js +++ b/core/scripts/server.ts @@ -1,15 +1,28 @@ -const fs = require('fs') +import { serverHooksExecutors } from '@vue-storefront/core/server/hooks' +let config = require('config') const path = require('path') -const express = require('express') -const ms = require('ms') -const compile = require('lodash.template') +const glob = require('glob') const rootPath = require('app-root-path').path const resolve = file => path.resolve(rootPath, file) +const serverExtensions = glob.sync('src/modules/*/server.{ts,js}') +const configProviders: Function[] = [] + +serverExtensions.map(serverModule => { + const module = require(resolve(serverModule)) + if (module.configProvider && typeof module.configProvider === 'function') { + configProviders.push(module.configProvider) + } +}) + +serverHooksExecutors.afterProcessStarted(config.server) +const express = require('express') +const ms = require('ms') +const request = require('request'); const cache = require('./utils/cache-instance') const apiStatus = require('./utils/api-status') const HTMLContent = require('../pages/Compilation') -let config = require('config') +const ssr = require('./utils/ssr-renderer') const compileOptions = { escape: /{{([^{][\s\S]+?[^}])}}/g, @@ -18,31 +31,16 @@ const compileOptions = { const NOT_ALLOWED_SSR_EXTENSIONS_REGEX = new RegExp(`(.*)(${config.server.ssrDisabledFor.extensions.join('|')})$`) const isProd = process.env.NODE_ENV === 'production' -process.noDeprecation = true +process['noDeprecation'] = true const app = express() -function createRenderer (bundle, clientManifest, template) { - // https://github.com/vuejs/vue/blob/dev/packages/vue-server-renderer/README.md#why-use-bundlerenderer - return require('vue-server-renderer').createBundleRenderer(bundle, { - clientManifest, - // runInNewContext: false, - cache: require('lru-cache')({ - max: 1000, - maxAge: 1000 * 60 * 15 - }) - }) -} +serverHooksExecutors.afterApplicationInitialized({ app, config: config.server, isProd }) + +const templatesCache = ssr.initTemplatesCache(config, compileOptions) -const templatesCache = {} let renderer -for (const tplName of Object.keys(config.ssr.templates)) { - const fileName = resolve(config.ssr.templates[tplName]) - if (fs.existsSync(fileName)) { - const template = fs.readFileSync(fileName, 'utf-8') - templatesCache[tplName] = compile(template, compileOptions) - } -} + if (isProd) { // In production: create server renderer using server bundle and index HTML // template from real fs. @@ -52,22 +50,16 @@ if (isProd) { // src/index.template.html is processed by html-webpack-plugin to inject // build assets and output as dist/index.html. // TODO: Add dynamic templates loading from (config based?) list - renderer = createRenderer(bundle, clientManifest) + renderer = ssr.createRenderer(bundle, clientManifest) } else { // In development: setup the dev server with watch and hot-reload, // and create a new renderer on bundle / index template update. require(resolve('core/build/dev-server'))(app, (bundle, template) => { - templatesCache['default'] = compile(template, compileOptions) // Important Notice: template switching doesn't work with dev server because of the HMR - renderer = createRenderer(bundle) + templatesCache['default'] = ssr.compileTemplate(template, compileOptions) // Important Notice: template switching doesn't work with dev server because of the HMR + renderer = ssr.createRenderer(bundle) }) } -function clearSSRContext (context) { - Object.keys(context.server).forEach(key => delete context.server[key]) - delete context.output['cacheTags'] - delete context['meta'] -} - function invalidateCache (req, res) { if (config.server.useOutputCache) { if (req.query.tag && req.query.key) { // clear cache pages for specific query tag @@ -84,6 +76,9 @@ function invalidateCache (req, res) { tags = req.query.tag.split(',') } const subPromises = [] + + serverHooksExecutors.beforeCacheInvalidated({ tags, req }) + tags.forEach(tag => { if (config.server.availableCacheTags.indexOf(tag) >= 0 || config.server.availableCacheTags.find(t => { return tag.indexOf(t) === 0 @@ -95,12 +90,28 @@ function invalidateCache (req, res) { console.error(`Invalid tag name ${tag}`) } }) + + 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) }) + + if (config.server.invalidateCacheForwarding) { // forward invalidate request to the next server in the chain + if (!req.query.forwardedFrom && config.server.invalidateCacheForwardUrl) { // don't forward forwarded requests + request(config.server.invalidateCacheForwardUrl + req.query.tag + '&forwardedFrom=vs', {}, (err, res, body) => { + if (err) { console.error(err); } + try { + if (body && JSON.parse(body).code !== 200) console.log(body); + } catch (e) { + console.error('Invalid Cache Invalidation response format', e) + } + }); + } + } } else { apiStatus(res, 'Invalid parameters for Clear cache request', 500) console.error('Invalid parameters for Clear cache request') @@ -110,7 +121,7 @@ function invalidateCache (req, res) { } } -const serve = (path, cache, options) => express.static(resolve(path), Object.assign({ +const serve = (path, cache, options?) => express.static(resolve(path), Object.assign({ fallthrough: false, setHeaders: cache && isProd ? function (res, path) { const mimeType = express.static.mime.lookup(path); @@ -132,11 +143,7 @@ app.use('/service-worker.js', serve('dist/service-worker.js', false, { } })) -const serverExtensions = require(resolve('src/server')) -serverExtensions.registerUserServerRoutes(app) - app.post('/invalidate', invalidateCache) - app.get('/invalidate', invalidateCache) app.get('*', (req, res, next) => { @@ -170,26 +177,7 @@ app.get('*', (req, res, next) => { res.status(202).end(HTMLContent) return next() } - const context = { - url: decodeURI(req.url), - output: { - prepend: (context) => { return '' }, // these functions can be replaced in the Vue components to append or prepend some content AFTER all other things are rendered. So in this function You may call: output.prepend() { return context.renderStyles() } to attach styles - append: (context) => { return '' }, - appendHead: (context) => { return '' }, - template: 'default', - cacheTags: new Set() - }, - server: { - app: app, - response: res, - request: req - }, - meta: null, - vs: { - config: config, - storeCode: req.header('x-vs-store-code') ? req.header('x-vs-store-code') : process.env.STORE_CODE - } - } + const context = ssr.initSSRRequestContext(app, req, res, config) renderer.renderToString(context).then(output => { if (!res.get('content-type')) { res.setHeader('Content-Type', 'text/html') @@ -201,18 +189,22 @@ app.get('*', (req, res, next) => { res.setHeader('X-VS-Cache-Tags', cacheTags) console.log(`cache tags for the request: ${cacheTags}`) } - const contentPrepend = (typeof context.output.prepend === 'function') ? context.output.prepend(context) : '' - const contentAppend = (typeof context.output.append === 'function') ? context.output.append(context) : '' - - output = contentPrepend + output + contentAppend - if (context.output.template) { // case when we've got the template name back from vue app - if (!isProd) context.output.template = 'default' // in dev mode we can not use pre-rendered HTML templates - if (templatesCache[context.output.template]) { // please look at: https://github.com/vuejs/vue/blob/79cabadeace0e01fb63aa9f220f41193c0ca93af/src/server/template-renderer/index.js#L87 for reference - output = templatesCache[context.output.template](context).replace('', output) - } else { - throw new Error(`The given template name ${context.output.template} does not exist`) - } + + const beforeOutputRenderedResponse = serverHooksExecutors.beforeOutputRenderedResponse({ + req, + res, + context, + output, + isProd + }) + + if (typeof beforeOutputRenderedResponse.output === 'string') { + output = beforeOutputRenderedResponse.output + } else if (typeof beforeOutputRenderedResponse === 'string') { + output = beforeOutputRenderedResponse } + + output = ssr.applyAdvancedOutputProcessing(context, output, templatesCache, isProd); if (config.server.useOutputCache && cache) { cache.set( 'page:' + req.url, @@ -220,12 +212,28 @@ app.get('*', (req, res, next) => { tagsArray ).catch(errorHandler) } - res.end(output) + + const afterOutputRenderedResponse = serverHooksExecutors.afterOutputRenderedResponse({ + req, + res, + context, + output, + isProd + }) + + if (typeof afterOutputRenderedResponse.output === 'string') { + res.end(afterOutputRenderedResponse.output) + } else if (typeof afterOutputRenderedResponse === 'string') { + res.end(afterOutputRenderedResponse) + } else { + res.end(output) + } + console.log(`whole request [${req.url}]: ${Date.now() - s}ms`) next() }).catch(errorHandler) .finally(() => { - clearSSRContext(context) + ssr.clearContext(context) }) } @@ -245,13 +253,11 @@ app.get('*', (req, res, next) => { res.end(output.body) } else { res.setHeader('Content-Type', 'text/html') - res.end(output.body) + res.end(output) } - res.end(output) console.log(`cache hit [${req.url}], cached request: ${Date.now() - s}ms`) next() } else { - res.setHeader('Content-Type', 'text/html') res.setHeader('X-VS-Cache', 'Miss') console.log(`cache miss [${req.url}], request: ${Date.now() - s}ms`) dynamicRequestHandler(renderer) // render response @@ -263,21 +269,29 @@ app.get('*', (req, res, next) => { } if (config.server.dynamicConfigReload) { - delete require.cache[require.resolve('config')] + const cachedConfigModule = require.cache[require.resolve('config')] + if (cachedConfigModule) { + delete cachedConfigModule.parent.children + delete require.cache[require.resolve('config')] + } config = require('config') // reload config - if (typeof serverExtensions.configProvider === 'function') { - serverExtensions.configProvider(req).then(loadedConfig => { - config = Object.assign(config, loadedConfig) // merge loaded conf with build time conf - dynamicCacheHandler() - }).catch(error => { - if (config.server.dynamicConfigContinueOnError) { - dynamicCacheHandler() - } else { - console.log('config provider error:', error) - if (req.url !== '/error') { - res.redirect('/error') - } - dynamicCacheHandler() + if (configProviders.length > 0) { + configProviders.forEach(configProvider => { + if (typeof configProvider === 'function') { + configProvider(req).then(loadedConfig => { + config = config.util.extendDeep(config, loadedConfig) + dynamicCacheHandler() + }).catch(error => { + if (config.server.dynamicConfigContinueOnError) { + dynamicCacheHandler() + } else { + console.log('config provider error:', error) + if (req.url !== '/error') { + res.redirect('/error') + } + dynamicCacheHandler() + } + }) } }) } else { @@ -294,7 +308,11 @@ const host = process.env.HOST || config.server.host const start = () => { app.listen(port, host) .on('listening', () => { - console.log(`Vue Storefront Server started at http://${host}:${port}`) + 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') { diff --git a/core/scripts/static-server.ts b/core/scripts/static-server.ts new file mode 100644 index 000000000..f4a820c48 --- /dev/null +++ b/core/scripts/static-server.ts @@ -0,0 +1,42 @@ +import express from 'express' +import config from 'config' +import path from 'path' +const app = express() + +const rootPath = require('app-root-path').path +const resolve = file => path.resolve(rootPath, file) +const isProd = true + +const serve = (path, cache, options) => express.static(resolve(path), Object.assign({ + maxAge: cache && isProd ? 2592000000 : 0, // 1 month in milliseconds = 1000 * 60 * 60 * 24 * 30 = 2592000000 + fallthrough: false +}, options)) + +app.use('/', serve(resolve(config.staticPages.destPath), true, { + setHeaders: function (res, path, stat) { + if (path.endsWith('.svg')) { + res.set('Content-Type', 'image/svg+xml; charset=UTF-8') + } else { + if (!path.endsWith('.js')) { + res.set('Content-Type', 'text/html; charset=UTF-8') + } + }// TODO: add better mime type guessing + } +})) + +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(`Vue Storefront Static Server started at http://${host}:${port}`) + }) + .on('error', (e: { code: string }) => { + 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 new file mode 100644 index 000000000..66a92bc62 --- /dev/null +++ b/core/scripts/utils/catalog-client.ts @@ -0,0 +1,57 @@ +import queryString from 'query-string' +import fetch from 'isomorphic-fetch' + +export const processURLAddress = (url: string = '', config: any) => { + if (url.startsWith('/')) return `${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) + + const httpQuery: { + size: number, + from: number, + sort: string, + _source_exclude?: string[], + _source_include?: string[], + q?: string, + request?: string + } = { + size: request.size, + from: request.from, + sort: request.sort + } + + 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(elasticsearchQueryBody) + } + 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(elasticsearchQueryBody) : null + }) + .then(resp => { return resp.json() }) + .catch(error => { + throw new Error('FetchError in request to ES: ' + error.toString()) + }) +} diff --git a/core/scripts/utils/page-generator.ts b/core/scripts/utils/page-generator.ts new file mode 100644 index 000000000..ec577b19f --- /dev/null +++ b/core/scripts/utils/page-generator.ts @@ -0,0 +1,27 @@ +import fs from 'fs-extra' +import path from 'path' +function saveRenderedPage (destPath, output) { + return fs.outputFile(destPath, output) +} +function saveScripts (basePath, destPath) { + return fs.copy(path.join(basePath, 'dist'), path.join(destPath, 'dist')) +} +function saveAssets (basePath, destPath) { + return fs.copy(path.join(basePath, 'assets'), path.join(destPath, 'assets')) +} +function saveIndex (basePath, destPath) { +} +function saveSW (basePath, destPath) { + return fs.copy(path.join(basePath, 'dist', 'service-worker.js'), path.join(destPath, 'service-worker.js')) +} +function clearAll (destPath) { + return fs.removeSync(destPath) +} +export default { + saveRenderedPage, + saveAssets, + saveIndex, + saveSW, + saveScripts, + clearAll +} diff --git a/core/scripts/utils/sort-translations.js b/core/scripts/utils/sort-translations.js new file mode 100644 index 000000000..0a915b878 --- /dev/null +++ b/core/scripts/utils/sort-translations.js @@ -0,0 +1,19 @@ +const { readFileSync, writeFileSync } = require('fs'); +const { EOL } = require('os'); + +const fixTranslation = (filename) => { + const content = readFileSync(filename, 'utf8'); + + writeFileSync( + filename, + content + .split(EOL) + .sort() + .filter(line => line.length > 0) + .join(EOL) + EOL + ); +} + +process.argv.map(filename => { + filename.split('.').pop() === 'csv' && fixTranslation(filename); +}); diff --git a/core/scripts/utils/ssr-renderer.js b/core/scripts/utils/ssr-renderer.js new file mode 100644 index 000000000..ad3e2efc2 --- /dev/null +++ b/core/scripts/utils/ssr-renderer.js @@ -0,0 +1,131 @@ +const fs = require('fs') +const path = require('path') +const compile = require('lodash.template') +const rootPath = require('app-root-path').path +const resolve = file => path.resolve(rootPath, file) +const omit = require('lodash/omit') +const set = require('lodash/set') +const get = require('lodash/get') +const config = require('config') +const minify = require('html-minifier').minify + +function createRenderer (bundle, clientManifest, template) { + // https://github.com/vuejs/vue/blob/dev/packages/vue-server-renderer/README.md#why-use-bundlerenderer + return require('vue-server-renderer').createBundleRenderer(bundle, { + clientManifest, + // runInNewContext: false, + cache: require('lru-cache')({ + max: 1000, + maxAge: 1000 * 60 * 15 + }) + }) +} + +function getFieldsToFilter () { + const fields = [ + ...(config.ssr && (config.ssr.initialStateFilter || [])), + ...(config.ssr && (config.ssr.lazyHydrateFor || [])) + ] + + return fields +} + +function filterState (context) { + if (!config.ssr.useInitialStateFilter) { + return context + } + + for (const field of getFieldsToFilter()) { + const newValue = get(context.initialState, field, null) + set(context.state, field, newValue) + } + + if (!config.server.dynamicConfigReload) { + context.state = omit(context.state, ['config']) + } + + return omit(context, ['initialState']) +} + +function applyAdvancedOutputProcessing (context, output, templatesCache, isProd = true, relatvePaths = false, destDir = '', outputFilename = '') { + context = filterState(context) + const contentPrepend = (typeof context.output.prepend === 'function') ? context.output.prepend(context) : ''; + const contentAppend = (typeof context.output.append === 'function') ? context.output.append(context) : ''; + output = contentPrepend + output + contentAppend; + if (context.output.template) { // case when we've got the template name back from vue app + if (!isProd) { context.output.template = 'default'; } // in dev mode we can not use pre-rendered HTML templates + if (templatesCache[context.output.template]) { // please look at: https://github.com/vuejs/vue/blob/79cabadeace0e01fb63aa9f220f41193c0ca93af/src/server/template-renderer/index.js#L87 for reference + output = templatesCache[context.output.template](context).replace('', output); + } else { + throw new Error(`The given template name ${context.output.template} does not exist`); + } + } + if (relatvePaths) { + const relativePath = path.relative(outputFilename, destDir).replace('../', '') + output = output.replace(new RegExp('/dist', 'g'), `${relativePath}/dist`) + output = output.replace(new RegExp('/assets', 'g'), `${relativePath}/dist`) + output = output.replace(new RegExp('href="/', 'g'), `href="${relativePath}/`) + } + + if (config.server.useHtmlMinifier) { + console.debug('HTML Minifier is enabled') + output = minify(output, config.server.htmlMinifierOptions) + } + + if ((typeof context.output.filter === 'function')) { + output = context.output.filter(output, context) + } + + return output; +} + +function initTemplatesCache (config, compileOptions) { + const templatesCache = {} + for (const tplName of Object.keys(config.ssr.templates)) { + const fileName = resolve(config.ssr.templates[tplName]); + if (fs.existsSync(fileName)) { + const template = fs.readFileSync(fileName, 'utf-8'); + templatesCache[tplName] = compile(template, compileOptions); + } + } + return templatesCache +} + +function initSSRRequestContext (app, req, res, config) { + return { + url: decodeURI(req.url), + output: { + prepend: (context) => { return ''; }, + append: (context) => { return ''; }, + filter: (output, context) => { return output }, + appendHead: (context) => { return ''; }, + template: 'default', + cacheTags: new Set() + }, + server: { + app: app, + response: res, + request: req + }, + meta: null, + vs: { + config: config, + storeCode: typeof req.header === 'function' ? (req.header('x-vs-store-code') ? req.header('x-vs-store-code') : process.env.STORE_CODE) : process.env.STORE_CODE + } + }; +} + +function clearContext (context) { + Object.keys(context.server).forEach(key => delete context.server[key]) + delete context.output['cacheTags'] + delete context['meta'] +} + +module.exports = { + createRenderer, + initTemplatesCache, + initSSRRequestContext, + applyAdvancedOutputProcessing, + compileTemplate: compile, + clearContext +} diff --git a/core/server-entry.ts b/core/server-entry.ts index 5a0391550..530bef6ec 100755 --- a/core/server-entry.ts +++ b/core/server-entry.ts @@ -1,7 +1,6 @@ import union from 'lodash-es/union' - import { createApp } from '@vue-storefront/core/app' -import { HttpError } from '@vue-storefront/core/helpers/exceptions' +import { HttpError } from '@vue-storefront/core/helpers/internal' import storeCodeFromRoute from '@vue-storefront/core/lib/storeCodeFromRoute' import omit from 'lodash-es/omit' import pick from 'lodash-es/pick' @@ -9,13 +8,14 @@ import buildTimeConfig from 'config' import { AsyncDataLoader } from '@vue-storefront/core/lib/async-data-loader' import config from 'config' import { Logger } from '@vue-storefront/core/lib/logger' +import { RouterManager } from './lib/router-manager'; import queryString from 'query-string' function _commonErrorHandler (err, reject) { if (err.message.indexOf('query returned empty result') > 0) { reject(new HttpError(err.message, 404)) } else { - reject(new Error(err.message)) + reject(new Error(err.stack)) } } @@ -32,17 +32,11 @@ function _ssrHydrateSubcomponents (components, store, router, resolve, reject, a } })).then(() => { AsyncDataLoader.flush({ store, route: router.currentRoute, context: null } /* AsyncDataLoaderActionContext */).then((r) => { - if (buildTimeConfig.ssr.useInitialStateFilter) { - context.state = omit(store.state, config.ssr.initialStateFilter) - } else { - context.state = store.state - } - if (!buildTimeConfig.server.dynamicConfigReload) { // if dynamic config reload then we're sending config along with the request - context.state = omit(store.state, buildTimeConfig.ssr.useInitialStateFilter ? [...config.ssr.initialStateFilter, 'config'] : ['config']) - } else { + context.state = store.state + if (buildTimeConfig.server.dynamicConfigReload) { const excludeFromConfig = buildTimeConfig.server.dynamicConfigExclude const includeFromConfig = buildTimeConfig.server.dynamicConfigInclude - console.log(excludeFromConfig, includeFromConfig) + // console.log(excludeFromConfig, includeFromConfig) if (includeFromConfig && includeFromConfig.length > 0) { context.state.config = pick(context.state.config, includeFromConfig) } @@ -59,24 +53,30 @@ function _ssrHydrateSubcomponents (components, store, router, resolve, reject, a }) } +function getHostFromHeader (headers: string[]): string { + return headers['x-forwarded-host'] !== undefined ? headers['x-forwarded-host'] : headers['host'] +} + export default async context => { let storeCode = context.vs.storeCode if (config.storeViews.multistore === true) { if (!storeCode) { // this is from url - const currentRoute = Object.assign({ path: queryString.parseUrl(context.url).url/* this gets just the url path part */, host: context.server.request.headers.host }) + const currentRoute = Object.assign({ path: queryString.parseUrl(context.url).url/* this gets just the url path part */, host: getHostFromHeader(context.server.request.headers) }) storeCode = storeCodeFromRoute(currentRoute) - console.log(storeCode, currentRoute) } } - const { app, router, store } = await createApp(context, context.vs && context.vs.config ? context.vs.config : buildTimeConfig, storeCode) + const { app, router, store, initialState } = await createApp(context, context.vs && context.vs.config ? context.vs.config : buildTimeConfig, storeCode) + + RouterManager.flushRouteQueue() + context.initialState = initialState return new Promise((resolve, reject) => { const meta = (app as any).$meta() router.push(context.url) context.meta = meta router.onReady(() => { const matchedComponents = router.getMatchedComponents() - if (!matchedComponents.length) { - return reject(new HttpError('No components matched', 404)) + if (!matchedComponents.length || !matchedComponents[0]) { + return reject(new HttpError('No components matched', 404)) // TODO - don't redirect if already on page-not-found } Promise.all(matchedComponents.map((Component: any) => { const components = Component.mixins ? Array.from(Component.mixins) : [] diff --git a/core/server/hooks.ts b/core/server/hooks.ts new file mode 100644 index 000000000..f7941c795 --- /dev/null +++ b/core/server/hooks.ts @@ -0,0 +1,75 @@ +import { createListenerHook, createMutatorHook } from '@vue-storefront/core/lib/hooks' +import { Express, Request } from 'express'; + +// To add like tracing which needs to be done as early as possible + +const { + hook: afterProcessStartedHook, + executor: afterProcessStartedExecutor +} = createListenerHook() + +interface beforeCacheInvalidatedParamter { + tags: string[], + req: Request +} + +const { + hook: beforeCacheInvalidatedHook, + executor: beforeCacheInvalidatedExecutor +} = createListenerHook() + +const { + hook: afterCacheInvalidatedHook, + executor: afterCacheInvalidatedExecutor +} = createListenerHook() + +// beforeStartApp +interface Extend { + app: Express, + config: any, + isProd: boolean +} +const { + hook: afterApplicationInitializedHook, + executor: afterApplicationInitializedExecutor +} = createListenerHook() + +const { + hook: beforeOutputRenderedResponseHook, + executor: beforeOutputRenderedResponseExecutor +} = createMutatorHook() + +const { + hook: afterOutputRenderedResponseHook, + executor: afterOutputRenderedResponseExecutor +} = createMutatorHook() + +/** Only for internal usage in this module */ +const serverHooksExecutors = { + afterProcessStarted: afterProcessStartedExecutor, + afterApplicationInitialized: afterApplicationInitializedExecutor, + beforeOutputRenderedResponse: beforeOutputRenderedResponseExecutor, + afterOutputRenderedResponse: afterOutputRenderedResponseExecutor, + beforeCacheInvalidated: beforeCacheInvalidatedExecutor, + afterCacheInvalidated: afterCacheInvalidatedExecutor +} + +const serverHooks = { + /** Hook is fired right at the start of the app. + * @param void + */ + afterProcessStarted: afterProcessStartedHook, + /** + * + */ + afterApplicationInitialized: afterApplicationInitializedHook, + beforeOutputRenderedResponse: beforeOutputRenderedResponseHook, + afterOutputRenderedResponse: afterOutputRenderedResponseHook, + beforeCacheInvalidated: beforeCacheInvalidatedHook, + afterCacheInvalidated: afterCacheInvalidatedHook +} + +export { + serverHooks, + serverHooksExecutors +} diff --git a/core/store/actions.ts b/core/store/actions.ts new file mode 100644 index 000000000..3ddff726f --- /dev/null +++ b/core/store/actions.ts @@ -0,0 +1,11 @@ +import { ActionTree } from 'vuex' +import * as types from './mutation-types' +import RootState from '@vue-storefront/core/types/RootState' + +const actions: ActionTree = { + async resetUserInvalidateLock ({ commit }) { + commit(types.USER_TOKEN_INVALIDATE_LOCK_CHANGED, 0) + } +} + +export default actions diff --git a/core/store/getters.ts b/core/store/getters.ts new file mode 100644 index 000000000..4a2ef3267 --- /dev/null +++ b/core/store/getters.ts @@ -0,0 +1,8 @@ +import { GetterTree } from 'vuex' +import RootState from '@vue-storefront/core/types/RootState' + +const getters: GetterTree = { + getCurrentStoreView: state => state.storeView +} + +export default getters diff --git a/core/store/index.ts b/core/store/index.ts index 4968b1a4d..ee38dbdd0 100644 --- a/core/store/index.ts +++ b/core/store/index.ts @@ -2,6 +2,9 @@ import Vue from 'vue' import Vuex from 'vuex' import RootState from '@vue-storefront/core/types/RootState' import { once } from '@vue-storefront/core/helpers' +import actions from './actions' +import getters from './getters' +import mutations from './mutations' once('__VUE_EXTEND_VUEX__', () => { Vue.use(Vuex) @@ -21,7 +24,12 @@ const state = { ui: {}, newsletter: {}, wishlist: {}, - attribute: '', + attribute: { + list_by_code: {}, + list_by_id: {}, + blacklist: [], + labels: {} + }, category: { current_path: '', current_product_query: {}, @@ -45,7 +53,10 @@ const state = { let rootStore = new Vuex.Store({ // TODO: refactor it to return just the constructor to avoid event-bus and i18n shenanigans; challenge: the singleton management OR add i18n and eventBus here to rootStore instance? modules: { - state + state, + actions, + getters, + mutations }) export default rootStore diff --git a/core/store/mutation-types.ts b/core/store/mutation-types.ts new file mode 100644 index 000000000..05816ec5a --- /dev/null +++ b/core/store/mutation-types.ts @@ -0,0 +1,2 @@ +export const SN_ROOT = 'root' +export const USER_TOKEN_INVALIDATE_LOCK_CHANGED = SN_ROOT + '/USER_TOKEN_INVALIDATE_LOCK_CHANGED' diff --git a/core/store/mutations.ts b/core/store/mutations.ts new file mode 100644 index 000000000..59bf171cd --- /dev/null +++ b/core/store/mutations.ts @@ -0,0 +1,11 @@ +import { MutationTree } from 'vuex' +import * as types from './mutation-types' +import RootState from '@vue-storefront/core/types/RootState' + +const mutations: MutationTree = { + [types.USER_TOKEN_INVALIDATE_LOCK_CHANGED] (state, payload) { + state.userTokenInvalidateLock = payload + } +} + +export default mutations diff --git a/core/test/unit/helpers/buildFilterProductsQuery.spec.ts b/core/test/unit/helpers/buildFilterProductsQuery.spec.ts new file mode 100644 index 000000000..66938381f --- /dev/null +++ b/core/test/unit/helpers/buildFilterProductsQuery.spec.ts @@ -0,0 +1,178 @@ +import { buildFilterProductsQuery } from '@vue-storefront/core/helpers' + +jest.mock('remove-accents', () => jest.fn()); +jest.mock('@vue-storefront/core/modules/url/helpers', () => jest.fn()); +jest.mock('@vue-storefront/core/lib/multistore', () => jest.fn()); +jest.mock('config', () => ({ + products: { + 'defaultFilters': ['color', 'size', 'price', 'erin_recommends'] + } +})); +jest.mock('@vue-storefront/core/store', () => ({})); + +describe('buildFilterProductsQuery method', () => { + let currentCategory + + beforeEach(() => { + currentCategory = { + 'path': '1/2/20', + 'is_active': true, + 'level': 2, + 'product_count': 0, + 'children_count': '8', + 'parent_id': 2, + 'name': 'Women', + 'id': 20, + 'url_path': 'women/women-20', + 'url_key': 'women-20', + 'children_data': [ + { + 'id': 21, + 'children_data': [ + { + 'id': 23 + }, + { + 'id': 24 + }, + { + 'id': 25 + }, + { + 'id': 26 + } + ] + }, + { + 'id': 22, + 'children_data': [ + { + 'id': 27 + }, + { + 'id': 28 + } + ] + } + ], + '_score': null, + 'slug': 'women-20' + } + }); + + it('should build default query', () => { + const result = buildFilterProductsQuery(currentCategory) + const categoryFilter = result._appliedFilters.find(filter => filter.attribute === 'category_ids') + expect(categoryFilter).toBeDefined() + expect(categoryFilter.value.in).toEqual([20, 21, 23, 24, 25, 26, 22, 27, 28]) + }); + + it('should build query with color filters', () => { + const filters = { + color: [ + { + 'id': '49', + 'label': 'Black', + 'type': 'color', + 'attribute_code': 'color' + }, + { + 'id': '50', + 'label': 'Blue', + 'type': 'color', + 'attribute_code': 'color' + } + ] + } + const result = buildFilterProductsQuery(currentCategory, filters) + const categoryFilter = result._appliedFilters.find(filter => filter.attribute === 'color') + expect(categoryFilter).toBeDefined() + expect(categoryFilter.value.in).toEqual(['49', '50']) + }); + + it('should build query with single price from 0 filter', () => { + const filters = { + price: { + 'id': '0.0-50.0', + 'type': 'price', + 'from': 0, + 'to': 50, + 'label': '< $50', + 'attribute_code': 'price' + } + } + const result = buildFilterProductsQuery(currentCategory, filters) + const categoryFilter = result._appliedFilters.find(filter => filter.attribute === 'price') + expect(categoryFilter).toBeDefined() + expect(categoryFilter.value.lte).toEqual(50) + }); + + it('should build query with single price between 50-100 filter', () => { + const filters = { + price: { + 'id': '50.0-100.0', + 'type': 'price', + 'from': 50, + 'to': 100, + 'label': '$50 - 100', + 'attribute_code': 'price' + } + } + const result = buildFilterProductsQuery(currentCategory, filters) + const categoryFilter = result._appliedFilters.find(filter => filter.attribute === 'price') + expect(categoryFilter).toBeDefined() + expect(categoryFilter.value.gte).toEqual(50) + expect(categoryFilter.value.lte).toEqual(100) + }); + + it('should build query with price filters', () => { + const filters = { + price: [{ + 'id': '0.0-50.0', + 'type': 'price', + 'from': 0, + 'to': 50, + 'label': '< $50', + 'attribute_code': 'price' + }] + } + const result = buildFilterProductsQuery(currentCategory, filters) + const categoryFilter = result._appliedFilters.find(filter => filter.attribute === 'price') + expect(categoryFilter).toBeDefined() + expect(categoryFilter.value.lte).toEqual(50) + }); + + it('should build query with color and erin_recommends filters', () => { + const filters = { + color: [ + { + 'id': '49', + 'label': 'Black', + 'type': 'color', + 'attribute_code': 'color' + }, + { + 'id': '50', + 'label': 'Blue', + 'type': 'color', + 'attribute_code': 'color' + } + ], + erin_recommends: [ + { + 'id': '1', + 'label': 'Yes', + 'type': 'erin_recommends', + 'attribute_code': 'erin_recommends' + } + ] + } + const result = buildFilterProductsQuery(currentCategory, filters) + const colorFilter = result._appliedFilters.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') + expect(erinFilter).toBeDefined() + expect(erinFilter.value.in).toEqual(['1']) + }); +}); diff --git a/core/types/RootState.ts b/core/types/RootState.ts index be459a8b0..bec8d6bbc 100644 --- a/core/types/RootState.ts +++ b/core/types/RootState.ts @@ -10,7 +10,7 @@ export default interface RootState { shipping: any, user: any, wishlist: any, - attribute: string, + attribute: any, ui: any, newsletter: any, category: { diff --git a/docker-compose.yml b/docker-compose.yml index bb75b2ad7..97ade3f72 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: - './package.json:/var/www/package.json' - './src:/var/www/src' - './var:/var/www/var' + - './packages:/var/www/packages' tmpfs: - /var/www/dist ports: diff --git a/docker/vue-storefront/Dockerfile b/docker/vue-storefront/Dockerfile index f0aa2896b..cfdaa30cc 100644 --- a/docker/vue-storefront/Dockerfile +++ b/docker/vue-storefront/Dockerfile @@ -7,7 +7,8 @@ WORKDIR /var/www COPY package.json ./ COPY yarn.lock ./ -RUN apk add --no-cache --virtual .build-deps ca-certificates wget git \ +RUN apk add --no-cache --virtual .build-deps ca-certificates wget python make g++ \ + && apk add --no-cache git \ && yarn install --no-cache \ && apk del .build-deps diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 7aefa62af..b0c52d518 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -80,6 +80,7 @@ module.exports = { 'basics/graphql', 'basics/ssr-cache', 'basics/amp', + 'basics/static-generator', 'basics/e2e', 'basics/url' ], @@ -140,14 +141,14 @@ module.exports = { ], }, { - title: 'Archives', - collapsable: true, + title: 'Data Resolvers', + collapsable: false, children: [ - 'archives/modules', - 'archives/extensions', - 'archives/components' - ], - }, + 'data-resolvers/introduction', + 'data-resolvers/category-service', + 'data-resolvers/user-service', + ] + } ], }, }, diff --git a/docs/guide/basics/configuration.md b/docs/guide/basics/configuration.md index e157b2b6e..4382698a9 100644 --- a/docs/guide/basics/configuration.md +++ b/docs/guide/basics/configuration.md @@ -25,12 +25,44 @@ Please find the configuration properties reference below. ```json "server": { "host": "localhost", - "port": 3000 + "port": 3000, + "useHtmlMinifier": false, + "htmlMinifierOptions": { + "minifyJS": true, + "minifyCSS": true + }, + "useOutputCacheTagging": false, + "useOutputCache": false }, ``` Vue Storefront starts an HTTP server to deliver the SSR (server-side rendered) pages and static assets. Its node.js server is located in the `core/scripts/server.js`. This is the hostname and TCP port which Vue Storefront is binding. +When the `useHtmlMinifier` is set to true the generated SSR HTML is being minified [using the `htmlMinifierOptions`](https://www.npmjs.com/package/html-minifier#options-quick-reference). + +When the `useOutputCacheTagging` and `useOutputCache` options are enabled, Vue Storefront is storing the rendered pages in the Redis-based output cache. Some additional config options are available for the output cache. [Check the details](ssr-cache.md) + +## Seo + +```json +"seo": { + "useUrlDispatcher": true, + "disableUrlRoutesPersistentCache": true, + "defaultTitle": "Vuestore" +}, +``` + +When `config.seo.useUrlDispatcher` set to true the `product.url_path` and `category.url_path` fields are used as absolute URL addresses (no `/c` and `/p` prefixes anymore). Check the latest [`mage2vuestorefront`] snapshot and reimport Your products to properly set `url_path` fields. + +For example, when the `category.url_path` is set to `women/frauen-20` the product will be available under the following URL addresses: + +`http://localhost:3000/women/frauen-20` +`http://localhost:3000/de/women/frauen-20` + +For, `config.seo.disableUrlRoutesPersistentCache` - to not store the url mappings; they're stored in in-memory cache anyway so no additional requests will be made to the backend for url mapping; however it might cause some issues with url routing in the offline mode (when the offline mode PWA installed on homescreen got reloaded, the in-memory cache will be cleared so there won't potentially be the url mappings; however the same like with `product/list` the ServiceWorker cache SHOULD populate url mappings anyway) + +For, `config.seo.defaultTitle` is as name suggest it's default title for the store. + ## Redis ```json @@ -96,6 +128,27 @@ The SSR data is being completed in the `asyncData` static method. If this config If it's set to `false`, then **just the** `src/themes/default/pages/Product.vue` -> `asyncData` will be executed. This option is referenced in the [core/client-entry.ts](https://github.com/DivanteLtd/vue-storefront/blob/master/core/client-entry.ts) line: 85. +```json + "lazyHydrateFor": ["category-next.products", "homepage"], +``` + +Filters out given properties from `window.__INITIAL_STATE__` and enables [lazy hydration](https://github.com/maoberlehner/vue-lazy-hydration) on client side +Available out of the box for `category-next.products` and `homepage`. +## Max attempt of tasks + +```json +"queues": { + "maxNetworkTaskAttempts": 1, + "maxCartBypassAttempts": 1 +}, +``` + +This both option is used when you don't want re-attempting task of just X number time attempt task. + +`maxNetworkTaskAttempts` config variable is referenced in the [core/lib/sync/task.ts](https://github.com/DivanteLtd/vue-storefront/blob/master/core/lib/sync/task.ts) and It's reattempt if user token is invalid. + +`maxCartBypassAttempts` config variable is referenced in the [core/modules/cart/store/actions.ts](https://github.com/DivanteLtd/vue-storefront/blob/master/core/modules/cart/store/actions.ts) + ## Default store code ```json @@ -109,7 +162,7 @@ This option is used only in the [Multistore setup](../integrations/multistore.md ```json "storeViews": { "multistore": false, - "commonCache": true, + "commonCache": false, "mapStoreUrlsFor": ["de", "it"], ``` @@ -125,12 +178,7 @@ You should add all the multistore codes to the `mapStoreUrlsFor` as this propert "de": { "storeCode": "de", ``` - -```json - "disabled": true, -``` - -If the specific store is disabled, it won't be used to populate the routing table and won't be displayed in the `Language/Switcher.vue`. +This attribute is not inherited through the "extend" mechanism. ```json "storeId": 3, @@ -150,12 +198,14 @@ This is the store name as displayed in the `Language/Switcher.vue`. This URL is used only in the `Switcher` component. Typically it equals just to `/`. Sometimes you may like to have different store views running as separate Vue Storefront instances, even under different URL addresses. This is the situation when this property comes into action. Just take a look at how [Language/Switcher.vue](https://github.com/DivanteLtd/vue-storefront/blob/master/src/themes/default/components/core/blocks/Switcher/Language.vue) generates the list of the stores. It accepts not only path, but also domains as well. +This attribute is not inherited through the "extend" mechanism. ```json "appendStoreCode": true, ``` -By default store codes are appended at the end of every url. If you want to use domain only as store url, you can set it to `false`. +In default configuration store codes are appended at the end of every url. If you want to use domain only as store url, you can set it to `false`. +This attribute is not inherited through the "extend" mechanism. ```json "elasticsearch": { @@ -177,6 +227,14 @@ ElasticSearch settings can be overridden in the specific `storeView` config. You Taxes section is used by the [core/modules/catalog/helpers/tax](https://github.com/DivanteLtd/vue-storefront/blob/master/core/modules/catalog/helpers/tax). When `sourcePricesIncludesTax` is set to `true` it means that the prices indexed in the ElasticSearch already consists of the taxes. If it's set to `false` the taxes will be calculated runtime. +```json + "seo": { + "defaultTitle": 'Vuestore' + }, +``` + +SEO section's `defaultTitle` is used at the set title for the specific store. + The `defaultCountry` and the `defaultRegion` settings are being used for finding the proper tax rate for the anonymous, unidentified user (which country is not yet set). ```json @@ -196,6 +254,14 @@ The `defaultCountry` and the `defaultRegion` settings are being used for finding The internationalization settings are used by the translation engine (`defautlLocale`) and the [Language/Switcher.vue](https://github.com/DivanteLtd/vue-storefront/blob/master/src/themes/default/components/core/blocks/Switcher/Language.vue) (`fullCountryName`, `fullLanguageName`). `currencyCode` is used for some of the API calls (rendering prices, mostly) and `currencySign` is being used for displaying the prices in the frontend. + +```json + "extend": "de" +``` + +You can inherit settings from other storeview of your choice. Result config will be deep merged with chosen storeview by storecode set in `extend` property prioritizing current storeview values. +Keep in mind that `url`, `storeCode` and `appendStoreCode` attributes cannot be inherited from oter storeviews. + ## Entities ```json @@ -225,6 +291,12 @@ Vue Storefront product objects can be quite large. They consist of `configurable Please take a look at the [core/modules/cart](https://github.com/DivanteLtd/vue-storefront/tree/master/core/modules/cart). +```json + "optimizeShoppingCartOmitFields": ["configurable_children", "configurable_options", "media_gallery", "description", "category", "category_ids", "product_links", "stock", "description"], +``` + +You can specify which fields get stripped out of the Cart object, by changing the `optimizeShoppingCartOmitFields` array. + ```json "category": { "includeFields": [ "children_data", "id", "children_count", "sku", "name", "is_active", "parent_id", "level", "url_key", "product_count" ] @@ -234,11 +306,11 @@ Please take a look at the [core/modules/cart](https://github.com/DivanteLtd/vue- }, "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" ], + "includeFields": [ "type_id", "sku", "product_links", "tax_class_id", "special_price", "special_to_date", "special_from_date", "name", "price", "price_incl_tax", "original_price_incl_tax", "original_price", "special_price_incl_tax", "id", "image", "sale", "new", "url_key", "status" ], "excludeFields": [ "configurable_children", "description", "configurable_options", "sgn" ] }, "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", "product_links", "url_key", "status"], + "includeFields": [ "type_id", "sku", "name", "tax_class_id", "special_price", "special_to_date", "special_from_date", "price", "priceInclTax", "original_price_incl_tax", "original_price", "special_price_incl_t_ax", "id", "image", "sale", "new", "configurable_children.image", "configurable_children.sku", "configurable_children.price", "configurable_children.special_price", "configurable_children.price_incl_tax", "configurable_children.special_price_incl_tax", "configurable_children.original_price", "configurable_children.original_price_incl_tax", "configurable_children.color", "configurable_children.size", "configurable_children.id", "product_links", "url_key", "status"], "excludeFields": [ "description", "sgn"] }, "product": { @@ -337,6 +409,12 @@ If this option is set to `items`, Vue Storefront will calculate the cart count b These endpoints should point to the `vue-storefront-api` instance and typically, you're changing just the domain-name/base-url without touching the specific endpoint URLs, as it's related to the `vue-storefront-api` specifics. +```json + "productsAreReconfigurable": true +``` + +If this option is set to `true`, you can edit current options such as color or size in the cart view. Works only for configurable products. + ## Products ```json @@ -418,6 +496,12 @@ This is the `vue-storefront-api` endpoint for rendering product lists. Here, we have the sort field settings as they're displayed on the Category page. +```json + "systemFilterNames": ["sort"], +``` + +This is an array of query-fields which won't be treated as filter fields when in URL. + ```json "gallery": { "mergeConfigurableChildren": true @@ -498,7 +582,7 @@ Starting with Vue Storefront v1.6, we changed the default order-placing behavior "syncTasks": "INDEXEDDB", "newsletterPreferences": "INDEXEDDB", "ordersHistory": "INDEXEDDB", - "checkoutFieldValues": "LOCALSTORAGE" + "checkout": "LOCALSTORAGE" } }, ``` @@ -542,11 +626,18 @@ The `stock` section configures how the Vue Storefront behaves when the product i ```json "images": { "baseUrl": "https://demo.vuestorefront.io/img/", - "productPlaceholder": "/assets/placeholder.jpg" + "productPlaceholder": "/assets/placeholder.jpg", + "useExactUrlsNoProxy": false, + "useSpecificImagePaths": false, + "paths": { + "product": "/catalog/product" + } }, ``` -This section is to set the default base URL of product images. This should be a `vue-storefront-api` URL, pointing to its `/api/img` handler. The Vue Storefront API is in charge of downloading the local image cache from the Magento/Pimcore backend and does the resize/crop/scale operations to optimize the images for mobile devices and the UI. +This section is to set the default base URL of images. This should be a `vue-storefront-api` URL, pointing to its `/api/img` handler. The Vue Storefront API is in charge of downloading the local image cache from the Magento/Pimcore backend and does the resize/crop/scale operations to optimize the images for mobile devices and the UI. + +If you wan't to also show non-product image thumbnails you must set `useSpecificImagePaths` to `true` and remove `/catalog/product` from the end of your API `magento1.imgUrl` or `magento2.imgUrl` setting in your API's config file – e.g.: `http://magento-demo.local/media`. After that you can use the `pathType` parameter of the `getThumbnail()` mixin method to traverse other images than product ones. ## Install @@ -574,11 +665,18 @@ When `demomode` is set to `true`, Vue Storefront will display the "Welcome to Vu "sourcePriceIncludesTax": false, "defaultCountry": "DE", "defaultRegion": "", - "calculateServerSide": true + "calculateServerSide": true, + "userGroupId": null }, ``` -The taxes section is used by the [core/modules/catalog/helpers/tax](https://github.com/DivanteLtd/vue-storefront/tree/master/core/modules/catalog/helpers/tax.ts). When `sourcePricesIncludesTax` is set to `true` it means that the prices indexed in the Elasticsearch already consist of the taxes. If it's set to `false` the taxes will be calculated runtime. +The taxes section is used by the +[core/modules/catalog/helpers/tax](https://github.com/DivanteLtd/vue-storefront/tree/master/core/modules/catalog/helpers/tax.ts). +When `sourcePricesIncludesTax` is set to `true` it means that the prices +indexed in the Elasticsearch already consist of the taxes. If it's set +to `false` the taxes will be calculated runtime. The `userGroupId` +config does only work when you have set `sourcePriceIncludesTax` set to +`false` and `calculateServerSide` is set to `false`. The `defaultCountry` and the `defaultRegion` settings are being used for finding the proper tax rate for the anonymous, unidentified user (which country is not yet set). diff --git a/docs/guide/basics/ssr-cache.md b/docs/guide/basics/ssr-cache.md index bee33f7b4..e79df35ab 100644 --- a/docs/guide/basics/ssr-cache.md +++ b/docs/guide/basics/ssr-cache.md @@ -1,6 +1,10 @@ # SSR Cache -Vue Storefront generates the server-side rendered pages to improve SEO results. In the latest version of Vue Storefront, we added the output cache option (disabled by default) to improve performance. +Vue Storefront generates the server-side rendered pages and from version to improve SEO results. In the latest version of Vue Storefront, we added the output cache option (disabled by default) to improve performance - for both Vue Storefront and Vue Storefront API. + +:::warning Caution ! +Vue Storefront API uses exactly the same output cache mechanisms like Vue Storefront with the same configuration and CLI interface. +::: The output cache is set by the following `config/local.json` variables: @@ -12,7 +16,10 @@ The output cache is set by the following `config/local.json` variables: "api": "api", "useOutputCacheTagging": true, "useOutputCache": true, - "outputCacheDefaultTtl": 86400 + "outputCacheDefaultTtl": 86400, + "invalidateCacheKey": "aeSu7aip", + "invalidateCacheForwarding": false, + "invalidateCacheForwardUrl": "http://localhost:8080/invalidate?key=aeSu7aip&tag=", }, "redis": { "host": "localhost", @@ -68,8 +75,11 @@ npm run cache clear -- --tag=P198 npm run cache clear -- --tag=* ``` +**Note:** The commands presented above works exactly the same way in the `vue-storefront-api`. + Available tag keys are set in the `config.server.availableCacheTags` (by default: `"product", "category", "home", "checkout", "page-not-found", "compare", "my-account", "P", "C"`) + **Dynamic cache invalidation:** Recent version of [mage2vuestorefront](https://github.com/DivanteLtd/mage2vuestorefront) supports output cache invalidation. Output cache is tagged with the product and categories ID (products and categories used on a specific page). Mage2vuestorefront can invalidate the cache of a product and category pages if you set the following ENV variables: ```bash @@ -77,6 +87,12 @@ export VS_INVALIDATE_CACHE_URL=http://localhost:3000/invalidate?key=SECRETKEY&ta export VS_INVALIDATE_CACHE=1 ``` +:::warning Caution ! +All the official Vue Storefront data indexers including [magento1-vsbridge-indexer](https://github.com/DivanteLtd/magento1-vsbridge-indexer), [magento2-vsbridge-indexer](https://github.com/DivanteLtd/magento2-vsbridge-indexer) and [mage2vuestorefront](https://github.com/DivanteLtd/mage2vuestorefront) support the cache invalidation. If the cache is enabled in both API and Vue Storefront frontend app, please make sure you are properly using the `config.server.invalidateCacheForwardUrl` config variable as the indexers can send the cache invalidate request only to one URL (frontend or backend) and it should be forwarded. Please check the default forwarding URLs in the `default.json` and adjust the `key` parameter to the value of `server.invalidateCacheKey`. +::: + +For `magento1-vsbridge-indexer` and `magento2-vsbridge-indexer` please do use the proper settings in the Magento admin panel. + :::warning Security note Please note that `key=SECRETKEY` should be equal to `vue-storefront/config/local.json` value of `server.invalidateCacheKey` ::: diff --git a/docs/guide/basics/static-generator.md b/docs/guide/basics/static-generator.md new file mode 100644 index 000000000..b53b23ecc --- /dev/null +++ b/docs/guide/basics/static-generator.md @@ -0,0 +1,44 @@ +# Static Pages Generator + +Vue Storefront supports static (HTML) page generation rendering mode from version 1.11. By generating the static pages you avoids the need of running `core/scripts/server.js` and you can deploy your site statically to any hosting like Netlify, Github pages or Amazon S3. + +:::warning Important notice +Please note, that the `vue-storefront-api` endpoint should still be available for the application as the client's side rendering still uses the API to fetch the data. +::: + +The server rendered pages are being stored to disk files into `/static` folder. + + +## Available Commands + +To generate the static version of your site first you must build the application in production mode: + +``` +yarn build +``` + +Then you can run the static page generator: + +``` +yarn generate all +``` + +After this command being executed you can find all your category, product and CMS pages - also including the Home Page (`index.html`) rendered in the `/static` folder. + +Because of the absolute links being used by Vue Storefront and the CORS mode the generated files **will only work being served from HTTP server** and the `file://` protocol won't allow you to browse the site. + +There is a static files hosting included and you can run it by executing: + +``` +yarn static-server +``` + +Now you can open your browser and navigate to `http://localhost:3000` which URL is just hosting the `/static` folder. + +## Deployment +All your website and assets are placed under `/static` folder. Please deploy this folder to your hosting provider (root directory of the domain only!). + + +:::warning Important notice +Static Pages generator is an experimental feature and probably still requires some tweaks / improvements. Please do use it carefully. +::: diff --git a/docs/guide/components/modal.md b/docs/guide/components/modal.md index 56dd3852a..632c5a5b42 100644 --- a/docs/guide/components/modal.md +++ b/docs/guide/components/modal.md @@ -22,10 +22,12 @@ Simple modal component. Visibility of modal container is based on internal state ### Available props -| Prop | Type | Required | Default | Description | -| ----- | ------ | -------- | ------- | --------------------- | -| name | String | true | | Unique name of modal | -| delay | Number | false | 300 | Timeout to show modal | +| Prop | Type | Required | Default | Description | +| -------------- | ------ | -------- | -------------- | ------------------------------------------ | +| name | String | true | | Unique name of modal | +| delay | Number | false | 300 | Timeout to show modal | +| width | Number | false | 0 | Optional fixed width of content, in pixels | +| transitionName | String | false | 'fade-in-down' | Content transition style | ### Available Methods @@ -37,3 +39,15 @@ Simple modal component. Visibility of modal container is based on internal state ### Styles Core component doesn't have CSS styles. If you want to see an example of our implementation please look [here](https://github.com/DivanteLtd/vue-storefront/blob/master/src/themes/default/components/core/Modal.vue) + +### Transitions + +The default theme defines one transition, `fade-in-down`, which can be seen [here](https://github.com/DivanteLtd/vue-storefront/blob/master/src/themes/default/css/animations/_transitions.scss). This is the default value for the `transitionName` prop. Further transitions can be defined in a custom theme by following this example. Vue transitions are explained [here](https://vuejs.org/v2/guide/transitions.html). + +To have modal content display immediately, without any transition, just supply an empty string for the `transitionName` prop. For example: + +```html + + + +``` diff --git a/docs/guide/components/product.md b/docs/guide/components/product.md index e7728ee33..5f893281f 100644 --- a/docs/guide/components/product.md +++ b/docs/guide/components/product.md @@ -9,17 +9,17 @@ No props - `loading` - If `true` this indicates the product is currently being loaded from the backend. - `favorite` - An object that defines 1) if the current product is in the list of favorite products and 2) the name of an icon that will be shown to indicate its status in relation to being in the list of favorite products. - `compare` - Defines if the current product is in compare list. -- `product` -A computed property that represents the current product that is shown on the page. Initially gets its value from `product/productCurrent` Vuex store getter. Includes all the options like size and color that the user sets on the page. -- `originalProduct` - A computed property that represents the current product in its initial state. Gets its value from`product/productOriginal` Vuex store getter. -- `parentProduct` - A computed property that represents the current product parent product, if any. Gets its value from `product/productParent` Vuex store getter. +- `product` -A computed property that represents the current product that is shown on the page. Initially gets its value from `product/getCurrentProduct` Vuex store getter. Includes all the options like size and color that the user sets on the page. +- `originalProduct` - A computed property that represents the current product in its initial state. Gets its value from`product/getOriginalProduct` Vuex store getter. +- `parentProduct` - A computed property that represents the current product parent product, if any. Gets its value from `product/getParentProduct` Vuex store getter. - `attributesByCode` - A computed property that returns the list of all product attributes by their code. Gets its value from `attribute/attributeListByCode` Vuex store getter. - `attributesById` - A computed property that returns the list of all product attributes by their ID. Gets its value from `attribute/attributeListById` Vuex store getter. **This prop is not used anywhere**. -- `breadcrumbs` - A computed property that represents breadcrumbs for the current product. Gets its value from `product/breadcrumbs` Vuex store getter. -- `configuration` - A computed property that represents an object that shows which attributes (like size and color) are chosen on the product. Gets its value from `product/currentConfiguration` Vuex store getter. -- `options` - A computed property that represents an object that shows what attributes (like size and color) with what values are available on the product. Gets its value from `product/currentOptions` Vuex store getter. +- `breadcrumbs` - A computed property that represents breadcrumbs for the current product. Gets its value from `category-next/getBreadcrumbs` Vuex store getter. +- `configuration` - A computed property that represents an object that shows which attributes (like size and color) are chosen on the product. Gets its value from `product/getCurrentProductConfiguration` Vuex store getter. +- `options` - A computed property that represents an object that shows what attributes (like size and color) with what values are available on the product. Gets its value from `product/getCurrentProductOptions` Vuex store getter. - `category` - A computed property representing a category object of the current product. Gets its value from `category/getCurrentCategory` Vuex store getter. - `productName` - A computed property that represents a product name. Gets its value from `category/getCurrentCategory` Vuex store getter. -- `productId` - A computed property representing a product ID. Gets its value from `category/getCurrentCategory` Vuex store getter. +- `productId` - A computed property representing a product ID. Gets its value from `category/getCurrentCategory` Vuex store getter. - `isOnCompare` - A computed property that checks if a given product is in compare list. - `image` - A computed property that defines an image (thumbnail) that will be shown on the page and its size. - `customAttributes` - this is a subset of `attributesByCode` list of attributes that the current product has. @@ -55,7 +55,7 @@ Dispatches `product/reset` action that sets the current product to the original _Parameters_ -- `{store, route}` - An object that consists of references to the Vuex store and global router object. +- `{store, route}` - An object that consists of references to the Vuex store and global router object. #### stateCheck diff --git a/docs/guide/cookbook/module.md b/docs/guide/cookbook/module.md index de5fdad3a..3ec015afe 100644 --- a/docs/guide/cookbook/module.md +++ b/docs/guide/cookbook/module.md @@ -46,7 +46,7 @@ touch index.ts ```bash import { StorefrontModule } from '@vue-storefront/core/lib/modules'; -export const ExampleModule: StorefrontModule = function (app, store, router, moduleConfig, appConfig) { +export const ExampleModule: StorefrontModule = function ({app, store, router, moduleConfig, appConfig}) { } ``` @@ -70,7 +70,7 @@ Judging by this signature, you can access `store`, `router`, `config`s from your ```bash import { StorefrontModule } from '@vue-storefront/core/lib/modules'; -export const ExampleModule: StorefrontModule = function (app, store, router, moduleConfig, appConfig) { +export const ExampleModule: StorefrontModule = function ({app, store, router, moduleConfig, appConfig}) { console.log('Hello World and VSF!'); # Any punch line allowed! } ``` @@ -227,7 +227,7 @@ const exampleModuleStore = { } } -export const ExampleModule: StorefrontModule = function (app, store, router, moduleConfig, appConfig) { +export const ExampleModule: StorefrontModule = function ({app, store, router, moduleConfig, appConfig}) { // abridged ... ``` `namespaced` with `true` value means this `store` is encapsulated inside a module and not registered to global store. @@ -245,7 +245,7 @@ const exampleModuleStore = { } } -export const ExampleModule: StorefrontModule = function (app, store, router, moduleConfig, appConfig) { +export const ExampleModule: StorefrontModule = function ({app, store, router, moduleConfig, appConfig}) { store.registerModule('example-module', exampleModuleStore); } @@ -274,7 +274,7 @@ const exampleModuleStore = { plugins: ['examplePlugin'] } -export const ExampleModule: StorefrontModule = function (app, store, router, moduleConfig, appConfig) { +export const ExampleModule: StorefrontModule = function ({app, store, router, moduleConfig, appConfig}) { store.registerModule('example-module', exampleModuleStore); } ``` @@ -322,13 +322,13 @@ const newProductModule = { } } -export const ExampleModule: StorefrontModule = function (app, store, router, moduleConfig, appConfig) { +export const ExampleModule: StorefrontModule = function ({app, store, router, moduleConfig, appConfig}) { // abridged ... ``` 4. Run `extendStore` helper method to override or add to existing store `product` as follows : ```ts{4} -export const ExampleModule: StorefrontModule = function (app, store, router, moduleConfig, appConfig) { +export const ExampleModule: StorefrontModule = function ({app, store, router, moduleConfig, appConfig}) { store.registerModule('example-module', exampleModuleStore); extendStore('product', newProductModule); @@ -399,7 +399,7 @@ const examplePlugin = store => { const exampleRoutes = [{ name: 'liked', path: '/liked', component: Liked, alias: '/liked.html' }]; // compose the router we will use -export const ExampleModule: StorefrontModule = function (app, store, router, moduleConfig, appConfig) { +export const ExampleModule: StorefrontModule = function ({app, store, router, moduleConfig, appConfig}) { store.registerModule('example-module', exampleModuleStore); extendStore('product', newProductModule); @@ -443,7 +443,7 @@ const examplePlugin = store => { ```ts{11-13} // ...abridged -export const ExampleModule: StorefrontModule = function (app, store, router, moduleConfig, appConfig) { +export const ExampleModule: StorefrontModule = function ({app, store, router, moduleConfig, appConfig}) { store.registerModule('example-module', exampleModuleStore); extendStore('product', newProductModule); @@ -492,7 +492,7 @@ vi index.ts # of course you can open it with other editors! ```ts{8-12,17} // ...abridged -export const ExampleModule: StorefrontModule = function (app, store, router, moduleConfig, appConfig) { +export const ExampleModule: StorefrontModule = function ({app, store, router, moduleConfig, appConfig}) { store.registerModule('example-module', exampleModuleStore); // ... abridged ... @@ -556,7 +556,7 @@ vi index.ts # of course you can open it with other editors! ```ts{4} // ... abridged -export const ExampleModule: StorefrontModule = function (app, store, router, moduleConfig, appConfig) { +export const ExampleModule: StorefrontModule = function ({app, store, router, moduleConfig, appConfig}) { console.log(appConfig.products.defaultFilters); // "products": {"defaultFilters": ["color", "size", "price", "erin_recommends"]} // abridged ... diff --git a/docs/guide/cookbook/setup.md b/docs/guide/cookbook/setup.md index 81201c422..412e72d92 100644 --- a/docs/guide/cookbook/setup.md +++ b/docs/guide/cookbook/setup.md @@ -452,7 +452,6 @@ At [`vue-storefront-api/config/default.json`](https://github.com/DivanteLtd/vue- ], "de": { "storeCode": "de", - "disabled": true, "storeId": 3, "name": "German Store", "url": "/de", @@ -479,7 +478,6 @@ At [`vue-storefront-api/config/default.json`](https://github.com/DivanteLtd/vue- }, "it": { "storeCode": "it", - "disabled": true, "storeId": 4, "name": "Italian Store", "url": "/it", @@ -512,7 +510,6 @@ At [`vue-storefront-api/config/default.json`](https://github.com/DivanteLtd/vue- - `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. - - `disabled` means if this store is disabled. - `storeId` denotes store ID of the store. - `name` denotes the store name. - `url` denotes URL for the store. @@ -950,11 +947,10 @@ At [`vue-storefront/config/default.json`](https://github.com/DivanteLtd/vue-stor "defaultStoreCode": "", "storeViews": { "multistore": false, - "commonCache": true, + "commonCache": false, "mapStoreUrlsFor": ["de", "it"], "de": { "storeCode": "de", - "disabled": true, "storeId": 3, "name": "German Store", "url": "/de", @@ -981,7 +977,6 @@ At [`vue-storefront/config/default.json`](https://github.com/DivanteLtd/vue-stor }, "it": { "storeCode": "it", - "disabled": true, "storeId": 4, "name": "Italian Store", "url": "/it", @@ -1136,6 +1131,7 @@ At [`vue-storefront/config/default.json`](https://github.com/DivanteLtd/vue-stor "setupVariantByAttributeCode": true, "endpoint": "http://localhost:8080/api/product", "defaultFilters": ["color", "size", "price", "erin_recommends"], + "systemFilterNames": ["sort"], "filterFieldMapping": { "category.name": "category.name.keyword" }, @@ -1263,7 +1259,7 @@ At [`vue-storefront/config/default.json`](https://github.com/DivanteLtd/vue-stor "defaultLocale": "en-US", "currencyCode": "USD", "currencySign": "$", - "currencySignPlacement": "preppend", + "priceFormat": "{sign}{amount}", "dateFormat": "HH:mm D/M/YYYY", "fullCountryName": "United States", "fullLanguageName": "English", @@ -1316,14 +1312,6 @@ At [`vue-storefront/config/default.json`](https://github.com/DivanteLtd/vue-stor } ] }, - "coolBags": { - "filter": [ - { - "key": "category.name", - "value" : { "eq": "Women" } - } - ] - }, "bestSellers": { "filter": [ { diff --git a/docs/guide/core-themes/layouts.md b/docs/guide/core-themes/layouts.md index c7746bbde..1ec6767c8 100644 --- a/docs/guide/core-themes/layouts.md +++ b/docs/guide/core-themes/layouts.md @@ -184,3 +184,10 @@ output = contentPrepend + output + contentAppend; Please note that the `context` contains a lot of interesting features you can use to control the CSS, SCRIPT and META injection. [Read more on Vue SSR Styles and Scripts injection](https://ssr.vuejs.org/guide/build-config.html#client-config) **Note: [The context object = Vue.prototype.$ssrContext](https://ssr.vuejs.org/guide/head.html)** + + +## Output compression + +HTML Minifier has been added to Vue Storefront 1.11. To enable this feature please switch the `config.server.useHtmlMinifier`. You can set the specific configuration of the `htmlMinifier` using the `config.server.htmlMinifierOptions`. Read more on the [available configuration](https://www.npmjs.com/package/html-minifier). The minified output is tthen being cached by `SSR Output cache` mechanism. + +Output compression has been also enabled (if the `src/modules/server.ts` contains the `compression` module on the list). By default it works just for produdction builds. It uses the `gzip` compression by default. [Read more about the `compression` module](https://www.npmjs.com/package/compression) that we're using for this implementation. diff --git a/docs/guide/core-themes/service-workers.md b/docs/guide/core-themes/service-workers.md index f74b6aab9..5a3a05ff7 100644 --- a/docs/guide/core-themes/service-workers.md +++ b/docs/guide/core-themes/service-workers.md @@ -46,7 +46,7 @@ It allows you to send data to the Service Worker. For example, when the order is * @param {Object} product data format for products is described in /doc/ElasticSearch data formats.md */ [types.CHECKOUT_PLACE_ORDER] (state, order) { - const ordersCollection = Vue.prototype.$db.ordersCollection + const ordersCollection = StorageManager.get('orders') const orderId = entities.uniqueEntityId(order) // timestamp as a order id is not the best we can do but it's enough order.id = orderId.toString() order.transmited = false diff --git a/docs/guide/core-themes/webpack.md b/docs/guide/core-themes/webpack.md index 7c24dd4af..bb512cd5e 100644 --- a/docs/guide/core-themes/webpack.md +++ b/docs/guide/core-themes/webpack.md @@ -1,6 +1,6 @@ # Working with Webpack -To make Vue Storefront fast and developer-friendly, we use webpack under the hood. We need it to transpile assets, handle `.vue` iles, process all styles, and make our code a little more maintainable with linting provided by eslint. With that, you don't need to worry about configuring it by hand to start working on Vue Storefront or to build your own theme for it. However, when you want to tweak it to your special needs, there is also a possibility to do that with extendable webpack configuration for each theme. +To make Vue Storefront fast and developer-friendly, we use webpack under the hood. We need it to transpile assets, handle `.vue` files, process all styles, and make our code a little more maintainable with linting provided by eslint. With that, you don't need to worry about configuring it by hand to start working on Vue Storefront or to build your own theme for it. However, when you want to tweak it to your special needs, there is also a possibility to do that with extendable webpack configuration for each theme. ## Core webpack build diff --git a/docs/guide/data-resolvers/category-service.md b/docs/guide/data-resolvers/category-service.md new file mode 100644 index 000000000..d85e47437 --- /dev/null +++ b/docs/guide/data-resolvers/category-service.md @@ -0,0 +1,7 @@ +# CategoryService + +## Methods + +#### `getCategories (serachOptions: DataResolver.CategorySearchOptions) => Promise` + +It fetches categories by given parameters. If the `config.entities.optimize` is enabled, the `includeFields` and `excludeFields` are set accordingly to `config.entities.category.includeFields` and `config.entities.category.excludeFields`. diff --git a/docs/guide/data-resolvers/introduction.md b/docs/guide/data-resolvers/introduction.md new file mode 100644 index 000000000..2d3d46344 --- /dev/null +++ b/docs/guide/data-resolvers/introduction.md @@ -0,0 +1,41 @@ +# Introduction + +## What are the data resolvers? + +The `data resolvers` are the way of manage the network/api calls and split them from the rest of application. All of available `data resolvers` you can find in the `core/data-resolver` directory. +If you want to trigger a network calls, you should create a new `data resolver`, and import it in the place where it's needed. + +## How to create a data resolver +First of all, please create a types for it under the namespace `DataResolver`, then just create a new data resolver like this example below: + + +```js +import { DataResolver } from './types/DataResolver'; +import { TaskQueue } from '@vue-storefront/core/lib/sync' +import Task from '@vue-storefront/core/lib/sync/types/Task' + +const headers = { + 'Accept': 'application/json, text/plain, */*', + 'Content-Type': 'application/json' +} + +const myNewNetworkCall = async (data: string): Promise => + TaskQueue.execute({ + url: processLocalizedURLAddress(/* some endpoint */), + payload: { + method: 'POST', + mode: 'cors', + headers, + body: JSON.stringify({ data }) + } + }) + +export const YourService: DataResolver.YourService = { + myNewNetworkCall, +} +``` + +## Available data resolvers + +- [CategoryService](category-service.md) +- [UserService](user-service.md) diff --git a/docs/guide/data-resolvers/user-service.md b/docs/guide/data-resolvers/user-service.md new file mode 100644 index 000000000..ead790f14 --- /dev/null +++ b/docs/guide/data-resolvers/user-service.md @@ -0,0 +1,35 @@ +# UserService + +## Methods + +#### `resetPassword: (email: string) => Promise` + +It resets the user password by given `email`. + +#### `login: (username: string, password: string) => Promise` + +It try to login user by given `username` and `password` + +#### `register: (customer: DataResolver.Customer, pssword: string) => Promise` + +Registering the new user by given user data (`customer`) and `password`. + +#### `updateProfile: (userProfile: UserProfile) => Promise` + +It updates the the current logged user profile (`userProfile`). + +#### `getProfile: () => Promise` + +It fetches the current logged user profile. + +#### `getOrdersHistory: () => Promise` + +It fetches order history by current logged user. + +#### `changePassword: (passwordData: PasswordData) => Promise` + +It changes the password for current logged user. + +#### `refreshToken: (refreshToken: string) => Promise` + +It refreshes the token for current user session by given `refreshToken`. diff --git a/docs/guide/data/data.md b/docs/guide/data/data.md index 19b643d7a..dee91152d 100644 --- a/docs/guide/data/data.md +++ b/docs/guide/data/data.md @@ -7,100 +7,10 @@ Vue storefront uses two primary data sources: ## Local data store -You can access localForage repositories through the `Vue.prototype.$db` object anywhere in the code, BUT all data-related operations SHOULD be placed in Vuex stores. +You can access localForage repositories through the `StorageManager` (`@vue-storefront/core/lib/storage-manager`) object anywhere in the code, BUT all data-related operations SHOULD be placed in Vuex stores. Details on localForage API can be found [here](http://localforage.github.io/localForage/) -We basically have the following data stores accessible in the browser (`/core/store/index.ts`): - -```js -Vue.prototype.$db = { - ordersCollection: new UniversalStorage( - localForage.createInstance({ - name: 'shop', - storeName: 'orders', - }), - ), - - categoriesCollection: new UniversalStorage( - localForage.createInstance({ - name: 'shop', - storeName: 'categories', - }), - ), - - attributesCollection: new UniversalStorage( - localForage.createInstance({ - name: 'shop', - storeName: 'attributes', - }), - ), - - cartsCollection: new UniversalStorage( - localForage.createInstance({ - name: 'shop', - storeName: 'carts', - }), - ), - - elasticCacheCollection: new UniversalStorage( - localForage.createInstance({ - name: 'shop', - storeName: 'elasticCache', - }), - ), - - productsCollection: new UniversalStorage( - localForage.createInstance({ - name: 'shop', - storeName: 'products', - }), - ), - - claimsCollection: new UniversalStorage( - localForage.createInstance({ - name: 'shop', - storeName: 'claims', - }), - ), - - wishlistCollection: new UniversalStorage( - localForage.createInstance({ - name: 'shop', - storeName: 'wishlist', - }), - ), - - compareCollection: new UniversalStorage( - localForage.createInstance({ - name: 'shop', - storeName: 'compare', - }), - ), - - usersCollection: new UniversalStorage( - localForage.createInstance({ - name: 'shop', - storeName: 'user', - }), - ), - - syncTaskCollection: new UniversalStorage( - localForage.createInstance({ - name: 'shop', - storeName: 'syncTasks', - }), - ), - - checkoutFieldsCollection: new UniversalStorage( - localForage.createInstance({ - name: 'shop', - storeName: 'checkoutFieldValues', - }), - ), -}; -``` - ## Example Vuex store Here you have an example on how the Vuex store should be constructed. Please notice the _Ajv data validation_: @@ -148,7 +58,7 @@ const mutations = { * @param {Object} product data format for products is described in /doc/ElasticSearch data formats.md */ [types.CHECKOUT_PLACE_ORDER](state, order) { - const ordersCollection = Vue.prototype.$db.ordersCollection; + const ordersCollection = StorageManager.ordersCollection; const orderId = entities.uniqueEntityId(order); // timestamp as a order id is not the best we can do but it's enough order.order_id = orderId.toString(); order.transmited = false; diff --git a/docs/guide/data/static-data.md b/docs/guide/data/static-data.md index 73987fa57..b14aa9a3f 100644 --- a/docs/guide/data/static-data.md +++ b/docs/guide/data/static-data.md @@ -2,19 +2,7 @@ In Vue Storefront, we can use CMS Static Blocks and CMS Static Pages from Magento 2. -## Old solution - -Until version 1.6, the `magento2-cms-extention` (we can find it in: `src/modules/magento-2-cms/`). It's deprecated. - -### How it works? - -To display CMS data, we need a `SnowdogApps/magento2-cms-api` module installed in the Magento 2 instance. The extension fetches data using the API and displays compiled content on the storefront. You can display blocks and pages by ID or identifiers—it supports different store IDs in a multi-languages store. Find more details [here](https://github.com/DivanteLtd/vue-storefront/tree/master/src/modules/magento-2-cms) - -This solution will be deprecated soon. - -## New solution - -From version 1.6, thanks to @yuriboyko,we have a better solution for static data—it's added to the Elasticsearch database and is using qraphQL query, displayed on the storefront. +From version 1.6, thanks to @yuriboyko we have a better solution for static data—it's added to the Elasticsearch database and is using qraphQL query, displayed on the storefront. ### How it works? diff --git a/docs/guide/installation/linux-mac.md b/docs/guide/installation/linux-mac.md index 349a45d5f..7301c48e4 100644 --- a/docs/guide/installation/linux-mac.md +++ b/docs/guide/installation/linux-mac.md @@ -10,7 +10,7 @@ Let's go! Already included in `vue-storefront` and `vue-storefront-api` Docker images (required locally, if you do not use containerization): -- Node.js [Active LTS](https://nodejs.org/en/) (>=8.0.0) +- Node.js [Active LTS](https://nodejs.org/en/) (>=10.x) - [Yarn](https://yarnpkg.com/en/docs/install) (>=1.0.0) - [ImageMagick](https://www.imagemagick.org/script/index.php) (to fit, resize and crop images) diff --git a/docs/guide/installation/production-setup.md b/docs/guide/installation/production-setup.md index 238815862..6d29f333a 100644 --- a/docs/guide/installation/production-setup.md +++ b/docs/guide/installation/production-setup.md @@ -237,7 +237,6 @@ Please find the key sections of the `vue-storefront/config/local.json` file desc ], "multistore": true, "de": { - "disabled": false, "elasticsearch": { "httpAuth": "", "host": "https://prod.vuestorefront.io/api/catalog", @@ -245,7 +244,6 @@ Please find the key sections of the `vue-storefront/config/local.json` file desc } }, "it": { - "disabled": false, "elasticsearch": { "httpAuth": "", "host": "https://prod.vuestorefront.io/api/catalog", diff --git a/docs/guide/installation/vue-storefront/config/local.json b/docs/guide/installation/vue-storefront/config/local.json index ebcfbc4a1..d1f140731 100644 --- a/docs/guide/installation/vue-storefront/config/local.json +++ b/docs/guide/installation/vue-storefront/config/local.json @@ -11,7 +11,6 @@ ], "multistore": true, "de": { - "disabled": false, "elasticsearch": { "httpAuth": "", "host": "https://prod.vuestorefront.io/api/catalog", @@ -19,7 +18,6 @@ } }, "it": { - "disabled": false, "elasticsearch": { "httpAuth": "", "host": "https://prod.vuestorefront.io/api/catalog", diff --git a/docs/guide/integrations/multistore.md b/docs/guide/integrations/multistore.md index 2bdb592ab..09a3b5841 100644 --- a/docs/guide/integrations/multistore.md +++ b/docs/guide/integrations/multistore.md @@ -92,7 +92,6 @@ The last thing is to change the `vue-storefront/config/local.json` to configure "mapStoreUrlsFor": ["de", "it"], "de": { "storeCode": "de", - "disabled": true, "storeId": 3, "name": "German Store", "url": "/de", @@ -118,7 +117,6 @@ The last thing is to change the `vue-storefront/config/local.json` to configure }, "it": { "storeCode": "it", - "disabled": true, "storeId": 4, "name": "Italian Store", "url": "/it", diff --git a/docs/guide/integrations/payment-gateway.md b/docs/guide/integrations/payment-gateway.md index f0323053d..1712d3287 100644 --- a/docs/guide/integrations/payment-gateway.md +++ b/docs/guide/integrations/payment-gateway.md @@ -38,7 +38,7 @@ import config from 'config' export function afterRegistration({ Vue, config, store, isServer }) { // Place the order. Payload is empty as we don't have any specific info to add for this payment method '{}' const placeOrder = function () { - Vue.prototype.$bus.$emit('checkout-do-placeOrder', {}) + EventBus.$emit('checkout-do-placeOrder', {}) } if (!isServer) { @@ -47,17 +47,17 @@ export function afterRegistration({ Vue, config, store, isServer }) { 'title': 'Cash on delivery', 'code': 'cashondelivery', 'cost': 0, - 'costInclTax': 0, + 'cost_incl_tax': 0, 'default': true, 'offline': true } rootStore.dispatch('payment/addMethod', paymentMethodConfig) // Mount the info component when required. - Vue.prototype.$bus.$on('checkout-payment-method-changed', (paymentMethodCode) => { + EventBus.$on('checkout-payment-method-changed', (paymentMethodCode) => { if (paymentMethodCode === 'cashondelivery') { // Register the handler for what happens when they click the place order button. - Vue.prototype.$bus.$on('checkout-before-placeOrder', placeOrder) + EventBus.$on('checkout-before-placeOrder', placeOrder) // Dynamically inject a component into the order review section (optional) const Component = Vue.extend(InfoComponent) @@ -65,7 +65,7 @@ export function afterRegistration({ Vue, config, store, isServer }) { componentInstance.$mount('#checkout-order-review-additional') } else { // unregister the extensions placeorder handler - Vue.prototype.$bus.$off('checkout-before-placeOrder', placeOrder) + EventBus.$off('checkout-before-placeOrder', placeOrder) } }) } diff --git a/docs/guide/modules/cart.md b/docs/guide/modules/cart.md index 269bfa07e..d1cb52fc3 100644 --- a/docs/guide/modules/cart.md +++ b/docs/guide/modules/cart.md @@ -182,7 +182,7 @@ All state members should have been accessed only by getters. Please take a look - `isCartHashChanged` - comparing the `getLastCartHash` with the `getCurrentCartHash` in order to verify if we need a server sync or not, - `isSyncRequired` - checking if the `isCartHashChanged` is true OR if this is the first sync attempt (after the SSR), - `isTotalsSyncRequired` - same as `isSyncRequired` but for the totals (not the cart items), -- `isCartHashEmtpyOrChanged` - checks if `isCartHashChanged` or empty, +- `isCartHashEmptyOrChanged` - checks if `isCartHashChanged` or empty, - `getCartItems` - array of products in the shopping cart, - `isTotalsSyncEnabled` - check if the `config.cart.synchronize` is true + if we're online + if this is CSR request, - `isCartConnected` - check if the `getCartToken` is not empty - which means the `cart/connect` action has been called and we're OK to sync with the server, diff --git a/docs/guide/modules/catalog.md b/docs/guide/modules/catalog.md index 70cd5e3a4..c017fd94c 100644 --- a/docs/guide/modules/catalog.md +++ b/docs/guide/modules/catalog.md @@ -202,11 +202,7 @@ The following events are published from `product` store: - `EventBus.$emit('product-after-priceupdate', product)` - from [syncProductPrice](https://github.com/DivanteLtd/vue-storefront/blob/bd559f1baad7cd392bc5bae7b935a60484e2e6e5/src/store/modules/product.js#L33) after product price is synced with Magento; - `EventBus.$emit('product-after-configure', { product: product, configuration: configuration, selectedVariant: selectedVariant })` from `configureProductAsync` (called by `product/configure` action after `product/single`). This event provides the information about selected product variant on the product page - `EventBus.$emit('product-after-list', { query: query, start: start, size: size, sort: sort, entityType: entityType, result: resp })` - this event emits the current product list as it's returned by `product/list` providing the current filters etc. You can mark specific product list identifier by setting `meta` property; it's important because on single page this event can be executed multiple time for each individual block of products -- `EventBus.$emit('product-after-single', { key: key, options: options, product: cachedProduct })` - after single product has been loaded (invoked by `product/single` action) -- `EventBus.$emit('product-after-related', { key: key, items: items })` - invoked whenever the related products block is set for the current product; the key is the name of the related block and items are related products -- `EventBus.$emit('product-after-original', { original: product })` - invoked by `product/single` whenever product has been loaded -- `EventBus.$emit('product-after-parent', { parent: product })` - invoked externally by `product/checkConfigurableParent` provides the current single product configurable parent -- `EventBus.$emit('product-after-reset', { })` - after product has been reseted (for example in the process of moving from one product page to another) +- `EventBus.$emit('product-after-single', { key: key, options: options, product: cachedProduct })` - after single product has been loaded (invoked by `product/single` action)related products #### Actions @@ -286,11 +282,11 @@ All state members should have been accessed only by getters. Please take a look ```js const getters = { - productParent: state => state.parent, - productCurrent: state => state.current, - currentConfiguration: state => state.current_configuration, - productOriginal: state => state.original, - currentOptions: state => state.current_options, + getParentProduct: state => state.parent, + getCurrentProduct: state => state.current, + getCurrentProductConfiguration: state => state.current_configuration, + getOriginalProduct: state => state.original, + getCurrentProductOptions: state => state.current_options, breadcrumbs: state => state.breadcrumbs, }; ``` diff --git a/docs/guide/modules/order.md b/docs/guide/modules/order.md index ad69ad013..04474134d 100644 --- a/docs/guide/modules/order.md +++ b/docs/guide/modules/order.md @@ -25,6 +25,7 @@ This module contains all the logic, components and store related to order operat - `paymentMethod` - returns `payment.additional_information[0]` from the `order` computed property - `billingAddress` - returns `billing_address` from the `order` computed property - `shippingAddress` - returns `extension_attributes.shipping_assignments[0].shipping.address` from the `order` computed property +- `singleOrderItems` - returns ordered products without `parent_id` **Methods** diff --git a/docs/guide/modules/user.md b/docs/guide/modules/user.md index 43df39bb0..b14965112 100644 --- a/docs/guide/modules/user.md +++ b/docs/guide/modules/user.md @@ -164,7 +164,7 @@ Calls the `vue-storefront-api` endpoint to send the password reset link to speci Called to login the user and receive the current token that can be used to authorize subsequent API calls. After user is successfully authorized the `user/me` action is dispatched to load the user profile data. -#### `register (context, { email, firstname, lastname, password })` +#### `register (context, { email, firstname, lastname, password, addresses })` Registers the user account in the eCommerce platform / Magento. diff --git a/docs/guide/upgrade-notes/README.md b/docs/guide/upgrade-notes/README.md index 83bee3a5d..c91a09a4b 100644 --- a/docs/guide/upgrade-notes/README.md +++ b/docs/guide/upgrade-notes/README.md @@ -2,6 +2,93 @@ 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.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. +- `src/modules/index.ts` was renamed to `client.ts`, exported property was renamed to `registerClientModules` +- Output compression module has been added; it's enabled by default on production builds; to disable it please switch the `src/modules/server.ts` configuration +- The [`formatCategoryLink`](https://github.com/DivanteLtd/vue-storefront/blob/develop/core/modules/url/helpers/index.ts) now supports multistore - adding the `storeCode` when necessary; it could have caused double store prefixes like `/de/de` - but probably only in the Breadcrumbs (#3359) +- All modules were refactored to new API. You can still register modules in previous format until 2.0 +- `DroppointShipping` and `magento-2-cms `modules were deleted +- example modules moved to https://github.com/DivanteLtd/vsf-samples +- `core/helpers/initCacheStorage.ts` merged with `StorageManager.ts` (import path alias for backward compatibility added) +- Old extensions mechanism (before VS 1.4) was finally removed after being deprecated for almost a year (`src/extensions` removal) +- Cache collections were reorganized. In most cases Local Storage keys remained untouched, only collection keys were unified. also they're used only in the core. Posting changes in case someone is using those collections in their modules; + - `syncTaskCollection` renamed to `syncTasks` + - `compareCollection` renamed to `compare` + - `cmsData` renamed to `cms` + - `cartsCollection` renamed to `carts` + - `checkoutFieldValues`, `checkoutFieldsCollection` renamed to `checkout` (`checkoutFieldsCollection` wasn’t used) + - `ordersCollection` and `orders` renamed to just `orders` (`ordersCollection` wasn’t used) + - `elasticCacheCollection` renamed to `elasticCache` + - `usersCollection` `usersData` merged and renamed to `user` + - `attributesCollection`, `attributes` renamed to just `attributes` + - `ordersHistoryCollection` merged to `user` cache where it belongs + - `categoriesCollection` renamed to categories + - Collections in theme like `claimsCollection` (claims modules) remained untouched +- `UserOrder` component has been renamed to `UserOrderHistory` and moved from `src/modules/order-history/components/UserOrders` to `@vue-storefront/core/modules/order/components/UserOrdersHistory`. This component was used in `MyOrders` component found here: `src/themes/default/components/core/blocks/MyAccount/MyOrders.vue`. In this file the `import` path has to be updated. +- `claims`, `promoted-offers`, `homepage` and `ui` modules have been moved from `@vue-storefront/src/modules` to `src/themes/default/store/` and reduced to stores only.
+Delete those folders:
+ -- `src/modules/claims`
+ -- `src/modules/promoted-offers`
+ -- `src/modules/homepage`
+ -- `src/modules/ui-store`
+Copy folder `theme/store/` from `theme default`.
+Register the stores copied in previous step in `src/themes/default/index.js`. To do that, import them along with `StorageManager` method, used to replace `claims beforeRegistration hook`. +```js +import { StorageManager } from '@vue-storefront/core/lib/storage-manager'; +import { store as claimsStore } from 'theme/store/claims' +import { store as homeStore } from 'theme/store/homepage' +import { store as uiStore } from 'theme/store/ui' +import { store as promotedStore } from 'theme/store/promoted-offers' +``` +Next, inside `initTheme` method use `store.registerModule` method to register the stores. +```js +StorageManager.init('claims'); +store.registerModule('claims', claimsStore); +store.registerModule('homepage', homeStore); +store.registerModule('ui', uiStore); +store.registerModule('promoted', promotedStore); +``` +- `WebShare` moved from `@vue-storefront/core/modules/social-share/components/WebShare.vue` to `@vue-storefront/src/themes/default/components/theme/WebShare.vue`. This component was used in `Product` component found here: `src/themes/default/pages/Product.vue`. In this file the `import` path has to be updated. + +- We've fixed the naming strategy for product prices; The following fields were renamed: `special_priceInclTax` -> `special_price_incl_tax`, `priceInclTax` -> `price_incl_tax`, `priceTax` -> `price_tax`; The names have been kept and marked as @deprecated. These fields will be **removed with Vue Storefront 2.0rc-1**. +- We've decreased the `localStorage` quota usage + error handling by introducing new config variables: +- `config.products.disablePersistentProductsCache` to not store products by SKU (by default it's on). Products are cached in ServiceWorker cache anyway so the `product/list` will populate the in-memory cache (`cache.setItem(..., memoryOnly = true)`); +- `config.seo.disableUrlRoutesPersistentCache` - to not store the url mappings; they're stored in in-memory cache anyway so no additional requests will be made to the backend for url mapping; however it might cause some issues with url routing in the offline mode (when the offline mode PWA installed on homescreen got reloaded, the in-memory cache will be cleared so there won't potentially be the url mappings; however the same like with `product/list` the ServiceWorker cache SHOULD populate url mappings anyway); +- `config.syncTasks.disablePersistentTaskQueue` to not store the network requests queue in service worker. Currently only the stock-check and user-data changes were using this queue. The only downside it introduces can be related to the offline mode and these tasks will not be re-executed after connectivity established, but just in a case when the page got reloaded while offline (yeah it might happen using ServiceWorker; `syncTasks` can't be re-populated in cache from SW) +- We've moved files from /store/lib to /lib. Basically to use it from the new directory you have to import now from `@vue-storefront/core/lib/store/` instead of `@vue-storefront/core/store/lib/`. These core files got changed: +```js +core/build/webpack.base.config.ts +core/lib/sync/task.ts +core/lib/storage-manager.ts +core/modules/catalog/helpers/search.ts +core/modules/catalog/store/attribute/mutations.ts +core/modules/catalog/store/category/actions.ts +core/modules/catalog/store/category/mutations.ts +core/modules/catalog/store/product/actions.ts +core/modules/catalog/store/tax/mutations.ts +core/modules/compare/store/actions.ts +core/modules/order/store/mutations.ts +core/modules/order/index.ts +core/modules/wishlist/store/actions.ts +``` +If by some reasons you wan't to have the `localStorage` back on for `Products by SKU`, `Url Routes` and `SyncTasks` - please just set these variables back to `false` in your `config/local.json`. + +- New page-not-found handling requires to update router/index.js in the theme. +- The option `config.ssr.lazyHydrateFor` with `category-next.products` value was introduced which is responsible for hydrating products list and loading them only on client side. It means there is no category products in the `__INITIAL__STATE__`. It's enabled by default. +- The modules: `Review`, `Mailer`, `Order`, `RecentlyViewed`, `InstantCheckout` are no longer loaded by default in the main bundle as they are loading on-demand on the related pages. +- Authentication guard was moved from user module router to `MyAccount` pages mixin. +- The getters `cmsBlocks`, `cmsBlockIdentifier`, `cmsBlockId` are deprecated. Please use `getCmsBlocks`, `getCmsBlockIdentifier`, `getCmsBlockId` instead. +- Translations for "Order #", "Price ", "Select size ", "You are logged in as" and "items" changed, they now include a placeholder for the value. Please refer to [this commit](https://github.com/DivanteLtd/vue-storefront/pull/3550/commits/366d31bf28a1e27a7f14b222369cba8fe0a6d3e0) in order to adjust them, otherwise they might get lost. +- `i18n.currencySignPlacement` config value is replaced by `i18n.priceFormat` so price format becomes more flexible +- Theme initialization needs to be modified in customized themes + - Delete the line `RouterManager.addRoutes(routes, router, true)`. This is now handled in `setupMultistoreRoutes`, including the default store. + - Optionally give theme routes priority, to ensure they override module routes if there are any conflicts. For example `setupMultistoreRoutes(config, router, routes, 10)`. + - See `/src/themes/default/index.js` for a complete example. +- In `storeView` config there is no more `disabled` flag for specific language config. Links for other languages will be displayed if specific `storeView` config exist. +- Categories can be filtered globally, to never be loaded, by setting `entities.category.filterFields` in local.json, e.g. `"filterFields": { "is_active": true }`. +- Categories can be filtered in the Breadcrumbs, by setting `entities.category.breadcrumbFilterFields` in local.json, e.g. `"breadcrumbFilterFields": { "include_in_menu": true }`. ## 1.10 -> 1.10.4 We've decreased the `localStorage` quota usage + error handling by introducing new config variables: @@ -14,7 +101,7 @@ If by some reasons you wan't to have the `localStorage` back on for `Products by ## 1.9 -> 1.10 -- Event `application-after-init` is now emitted by event bus instead of root Vue instance (app), so you need to listen to `Vue.prototype.$bus` (`Vue.prototype.$bus.$on()`) now +- Event `application-after-init` is now emitted by event bus instead of root Vue instance (app), so you need to listen to `Vue.prototype.$bus` (`EventBus.$on()`) now - The lowest supported node version is currently 8.10.0, - Module Mailchimp is removed in favor of Newsletter. `local.json` configuration under key `mailchimp` moved to key `newsletter`. - In multistore mode now there is a possibility to skip appending storecode to url with `appendStoreCode` config option. To keep the original behavior, it should be set to true. - @lukeromanowicz (#3048). @@ -120,7 +207,7 @@ this.$store.dispatch('notification/spawnNotification', ```` Change every store: ````js -Vue.prototype.$bus.$emit('notification', +EventBus.$emit('notification', ```` to: ````js @@ -431,11 +518,11 @@ The endpoints are also set by the `yarn installer` so You can try to reinstall V "includeFields": [ "attribute_code", "id", "entity_type_id", "options", "default_value", "is_user_defined", "frontend_label", "attribute_id", "default_frontend_label", "is_visible_on_front", "is_visible", "is_comparable" ] }, "productList": { - "includeFields": [ "type_id", "sku", "name", "price", "priceInclTax", "originalPriceInclTax", "id", "image", "sale", "new" ], + "includeFields": [ "type_id", "sku", "name", "price", "priceInclTax", "original_price_incl_tax", "id", "image", "sale", "new" ], "excludeFields": [ "configurable_children", "description", "configurable_options", "sgn", "tax_class_id" ] }, "productListWithChildren": { - "includeFields": [ "type_id", "sku", "name", "price", "priceInclTax", "originalPriceInclTax", "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" ], + "includeFields": [ "type_id", "sku", "name", "price", "priceInclTax", "original_price_incl_tax", "id", "image", "sale", "new", "configurable_children.image", "configurable_children.sku", "configurable_children.price", "configurable_children.special_price", "configurable_children.price_incl_tax", "configurable_children.special_price_incl_tax", "configurable_children.original_price", "configurable_children.original_price_incl_tax", "configurable_children.color", "configurable_children.size" ], "excludeFields": [ "description", "sgn", "tax_class_id" ] }, "product": { diff --git a/docs/guide/vuex/product-store.md b/docs/guide/vuex/product-store.md index 2a4b4d05e..f6e0ec7b2 100644 --- a/docs/guide/vuex/product-store.md +++ b/docs/guide/vuex/product-store.md @@ -50,10 +50,6 @@ The following events are published from `product` store: - `EventBus.$emit('product-after-configure', { product: product, configuration: configuration, selectedVariant: selectedVariant })` from `configureProductAsync` (called by `product/configure` action after `product/single`). This event provides the information about selected product variant on the product page. - `EventBus.$emit('product-after-list', { query: query, start: start, size: size, sort: sort, entityType: entityType, result: resp })` - this event emits the current product list as it's returned by `product/list`providing the current filters, etc. You can mark the specific product list identifier by setting the `meta` property; it's important because on a single page, this event can be executed multiple time for each individual block of products. - `EventBus.$emit('product-after-single', { key: key, options: options, product: cachedProduct })` - After single product has been loaded (invoked by `product/single` action). -- `EventBus.$emit('product-after-related', { key: key, items: items })` - Invoked whenever the related products block is set for the current product; the key is the name of the related block and items are related products. -- `EventBus.$emit('product-after-original', { original: product })` - Invoked by `product/single` whenever product has been loaded. -- `EventBus.$emit('product-after-parent', { parent: product })` - Invoked externally by `product/checkConfigurableParent` provides the current single product configurable parent. -- `EventBus.$emit('product-after-reset', { })` - After product has been reset (for example in the process of moving from one product page to another). ## Actions @@ -133,11 +129,11 @@ All state members should have been accessed only by getters. Please take a look ```js const getters = { - productParent: state => state.parent, - productCurrent: state => state.current, - currentConfiguration: state => state.current_configuration, - productOriginal: state => state.original, - currentOptions: state => state.current_options, + getParentProduct: state => state.parent, + getCurrentProduct: state => state.current, + getCurrentProductConfiguration: state => state.current_configuration, + getOriginalProduct: state => state.original, + getCurrentProductOptions: state => state.current_options, breadcrumbs: state => state.breadcrumbs, }; ``` diff --git a/docs/guide/vuex/sync-store.md b/docs/guide/vuex/sync-store.md index 142dbc37f..4052b8092 100644 --- a/docs/guide/vuex/sync-store.md +++ b/docs/guide/vuex/sync-store.md @@ -46,7 +46,7 @@ The `url` can contain two dynamic variable placeholders that will be expanded to An example URL with variables: `http://localhost:8080/api/cart/totals?token={{token}}&cartId={{cartId}}` :::tip Note -The task object and then the results are stored within the `tasksCollection` indexedDb data table under the key of `task.task_id` +The task object and then the results are stored within the `syncTasks` indexedDb/Local storage data table under the key of `task.task_id` ::: ![syncTasks local collection stores the tasks and the results](../images/syncTasks-example.png) diff --git a/docs/package.json b/docs/package.json index de53d5bc0..b3328ed70 100644 --- a/docs/package.json +++ b/docs/package.json @@ -1,7 +1,7 @@ { "name": "@vue-storefront/docs", "private": true, - "version": "1.10.5", + "version": "1.11.0", "scripts": { "docs:dev": "vuepress dev", "docs:build": "vuepress build", diff --git a/ecosystem.json b/ecosystem.json index 859ecbe0e..caf130c5e 100644 --- a/ecosystem.json +++ b/ecosystem.json @@ -5,7 +5,12 @@ "max_memory_restart": "1G", "instances": "4", "exec_mode": "cluster", - "script": "./core/scripts/server.js", + "env": { + "TS_NODE_PROJECT": "tsconfig-build.json", + "NODE_ENV": "production" + }, + "interpreter": "./node_modules/.bin/ts-node", + "script": "./core/scripts/server.ts", "node_args": "--max_old_space_size=1024", "log_date_format": "YYYY-MM-DD HH:mm:ss", "ignore_watch": [ diff --git a/lerna.json b/lerna.json index 083bb03aa..b083c58a8 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "lerna": "3.14.1", - "version": "1.10.5", + "version": "independent", "npmClient": "yarn", "useWorkspaces": true, "registry": "https://registry.npmjs.org/" diff --git a/package.json b/package.json index a73731562..10db51c87 100755 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "vue-storefront", - "version": "1.10.5", + "version": "1.11.0", "description": "A Vue.js, PWA eCommerce frontend", "private": true, "engines": { - "node": ">=8.x" + "node": ">=10.x" }, "repository": { "type": "git", @@ -26,15 +26,17 @@ }, "homepage": "https://github.com/DivanteLtd/vue-storefront/README.md", "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", "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 ./core/scripts/server.js", + "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/entry.ts", + "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/entry", + "dev:inspect": "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", @@ -43,12 +45,9 @@ "test:unit:watch": "jest -c test/unit/jest.conf.js --watch", "test:e2e": "cypress open", "test:e2e:ci": "cypress run", - "lint": "eslint --ext .js,.vue,.ts core src --fix", + "lint": "cross-env NODE_ENV=production eslint --ext .js,.vue,.ts core src --fix", "lerna": "lerna" }, - "pre-commit": [ - "lint" - ], "dependencies": { "@types/webpack": "^4.4.23", "@types/webpack-dev-server": "^3.1.1", @@ -56,12 +55,15 @@ "apollo-client": "^2.3.5", "apollo-link": "^1.2.2", "apollo-link-http": "^1.5.4", + "body-scroll-lock": "^2.6.4", "bodybuilder": "2.2.13", "config": "^1.30.0", "cross-env": "^3.1.4", "dayjs": "^1.8.15", "es6-promise": "^4.2.4", "express": "^4.14.0", + "fs-extra": "^8.1.0", + "glob": "^7.1.4", "graphql": "^0.13.2", "graphql-tag": "^2.9.2", "isomorphic-fetch": "^2.2.1", @@ -70,6 +72,7 @@ "magento2-rest-client": "github:DivanteLtd/magento2-rest-client", "ms": "^2.1.2", "pm2": "^2.10.4", + "proxy-polyfill": "^0.3.0", "redis-tag-cache": "^1.2.1", "reflect-metadata": "^0.1.12", "register-service-worker": "^1.5.2", @@ -80,6 +83,7 @@ "vue-carousel": "^0.6.9", "vue-gtm": "^2.0.0", "vue-i18n": "^8.0.0", + "vue-lazy-hydration": "^1.0.0-beta.9", "vue-lazyload": "^1.2.6", "vue-meta": "^1.5.3", "vue-no-ssr": "^0.2.2", @@ -129,18 +133,20 @@ "file-loader": "^1.1.11", "fs-exists-sync": "^0.1.0", "html-webpack-plugin": "^3.2.0", + "husky": "^2.6.0", "inquirer": "^3.3.0", "is-windows": "^1.0.1", "jest": "^24.8.0", + "jest-fetch-mock": "^2.1.2", "jest-serializer-vue": "^2.0.2", "jsonfile": "^4.0.0", "lerna": "^3.14.1", + "lint-staged": "^8.2.1", "mkdirp": "^0.5.1", "node-sass": "^4.12.0", "phantomjs-prebuilt": "^2.1.10", "postcss-flexbugs-fixes": "^4.1.0", "postcss-loader": "^3.0.0", - "pre-commit": "^1.2.2", "print-message": "^2.1.0", "rimraf": "^2.6.0", "sass-loader": "^7.1.0", @@ -184,10 +190,11 @@ "core/i18n", "src/extensions/*", "src/modules/*", + "src/trace/*", "src/themes/*", "docs", "core/modules/*", - "packages/*", - "test/unit" + "test/unit", + "packages/*" ] } diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore index 3c3629e64..c2658d7d1 100644 --- a/packages/cli/.gitignore +++ b/packages/cli/.gitignore @@ -1 +1 @@ -node_modules +node_modules/ diff --git a/packages/cli/README.md b/packages/cli/README.md index 5397a9479..aa5043fee 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -11,5 +11,6 @@ npm i -g @vue-storefront/cli After installing CLI you'll get access to following commands: - `vsf init [dirname]` - Use it to set up new VS project. The CLI will ask you a few questions and set up new, ready to work with Vue Storefront instance. +- `vsf generate-module [module-name]` - generates boilerplate for Vue Storefront module named `vsf-module-name` in a current directory - `vsf --help` - available commands - `vsf -version` - current version of cli diff --git a/packages/cli/bin/vsf.js b/packages/cli/bin/vsf.js deleted file mode 100755 index d5101c167..000000000 --- a/packages/cli/bin/vsf.js +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env node - -const command = process.argv[2] - - -switch (command) { - case 'init': - require('../scripts/install.js')(process.argv[3]) - break; - case '--help': - require('../scripts/manual.js')() - break; - case '--version': - console.log('v' + require('../package.json').version) - break; - default: - console.log('Unknown command. try one of those:\n') - require('../scripts/manual.js')() -} \ No newline at end of file diff --git a/packages/cli/boilerplates/module/.gitignore b/packages/cli/boilerplates/module/.gitignore new file mode 100644 index 000000000..04c01ba7b --- /dev/null +++ b/packages/cli/boilerplates/module/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ \ No newline at end of file diff --git a/packages/cli/boilerplates/module/README.md b/packages/cli/boilerplates/module/README.md new file mode 100644 index 000000000..af1a16065 --- /dev/null +++ b/packages/cli/boilerplates/module/README.md @@ -0,0 +1,17 @@ +# vsf-package + + +## Installation + +``` +yarn add vsf-package +``` + +## API + + + +## Development + +- `yarn build` - bundles package into main.js file with typescript/es6 transpilations +- `npm publish` - publishes package to NPM registry (automatically runs `yarn build` before publishing) \ No newline at end of file diff --git a/packages/cli/boilerplates/module/package.json b/packages/cli/boilerplates/module/package.json new file mode 100644 index 000000000..97c5aecac --- /dev/null +++ b/packages/cli/boilerplates/module/package.json @@ -0,0 +1,24 @@ +{ + "name": "vsf-package", + "version": "1.0.11", + "description": "", + "main": "dist/main.js", + "scripts": { + "build": "webpack --config node_modules/@vue-storefront/core/lib/modules/tools/module-build.config.js", + "prePublishOnly": "yarn build" + }, + "keywords": ["vue-storefront"], + "author": "", + "license": "MIT", + "dependencies": {}, + "devDependencies": { + "@vue-storefront/core": "^1.11.0", + "ts-loader": "^6.0.4", + "typescript": "^3.5.2", + "webpack": "^4.35.2", + "webpack-cli": "^3.3.5" + }, + "peerDependencies": { + "@vue-storefront/core": "^1.11.0" + } +} diff --git a/packages/cli/boilerplates/module/src/index.ts b/packages/cli/boilerplates/module/src/index.ts new file mode 100644 index 000000000..9dc90cd1d --- /dev/null +++ b/packages/cli/boilerplates/module/src/index.ts @@ -0,0 +1,14 @@ +import { StorefrontModule } from '@vue-storefront/core/lib/modules' +import { coreHooks } from '@vue-storefront/core/hooks' +import { extendStore } from '@vue-storefront/core/helpers' +import { ExampleStore, ExtendProductStore } from './store' + +export const ExampleModule: StorefrontModule = function ({store}) { + // You can access config passed to registerModule via moduleConfig variable + // This is how you register new Vuex modules + store.registerModule('example', ExampleStore) + // This is how you override properties of currently existing Vuex modules + extendStore('product', ExtendProductStore) + // This is how you can hook into various palces of the application + coreHooks.afterAppInit(() => console.log('Do something when application is initialized!')) +} diff --git a/packages/cli/boilerplates/module/src/store.ts b/packages/cli/boilerplates/module/src/store.ts new file mode 100644 index 000000000..ba44c5f5c --- /dev/null +++ b/packages/cli/boilerplates/module/src/store.ts @@ -0,0 +1,16 @@ +export const ExampleStore = { + state: { + message: 'Hello World' + } +} + +export const ExtendProductStore = { + actions: { + state: { + newprop: null + }, + list () { + console.log('Hello from extended action') + } + } +} diff --git a/packages/cli/boilerplates/module/tsconfig.json b/packages/cli/boilerplates/module/tsconfig.json new file mode 100644 index 000000000..bcf0447cd --- /dev/null +++ b/packages/cli/boilerplates/module/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "outDir": "./dist", + "strict": false, + "moduleResolution": "node" + }, + "include": [ + "**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file diff --git a/packages/cli/index.js b/packages/cli/index.js old mode 100644 new mode 100755 index 908ba8417..cece1378d --- a/packages/cli/index.js +++ b/packages/cli/index.js @@ -1 +1,21 @@ #!/usr/bin/env node + +const command = process.argv[2] + +switch (command) { + case 'init': + require('./scripts/install.js')(process.argv[3]) + break; + case 'init:module': + require('./scripts/generateModule.js')(process.argv[3]) + break; + case '--help': + require('./scripts/manual.js')() + break; + case '--version': + console.log('v' + require('../package.json').version) + break; + default: + console.log('Unknown command. try one of those:\n') + require('./scripts/manual.js')() +} diff --git a/packages/cli/package.json b/packages/cli/package.json index c6bea2521..423fec890 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -3,11 +3,8 @@ "version": "0.0.15", "description": "", "main": "index.js", - "scripts": { - "init": "./index.js" - }, "bin": { - "vsf": "./bin/vsf.js" + "vsf": "./index.js" }, "publishConfig": { "access": "public" @@ -17,8 +14,10 @@ "license": "MIT", "dependencies": { "execa": "^1.0.0", + "fs-extra": "^8.1.0", "inquirer": "^6.3.1", "listr": "^0.14.3", + "replace-in-file": "^4.1.1", "semver-sort": "^0.0.4" } } diff --git a/packages/cli/scripts/generateModule.js b/packages/cli/scripts/generateModule.js new file mode 100644 index 000000000..fd6c4c4be --- /dev/null +++ b/packages/cli/scripts/generateModule.js @@ -0,0 +1,24 @@ +const fse = require('fs-extra') +const path = require('path') +const replace = require('replace-in-file') +const cwd = process.cwd() +const boilerplatePath = path.resolve(__dirname, '../boilerplates/module') + +module.exports = function (moduleName) { + const modulePath = cwd + '/vsf-' + moduleName + const replacementOptions = { + files: [modulePath + '/**/*.*'], + from: 'vsf-package', + to: 'vsf-' + moduleName + } + + fse.copy(boilerplatePath, modulePath, (err) => { + if (err) { + console.error(err) + } else { + replace(replacementOptions) + .catch(error => console.error('Error occurred:', error)) + console.log('Module vsf-' + moduleName + ' has been succesfully created!\n cd vsf-' + moduleName) + } + }) +} diff --git a/packages/cli/scripts/install.js b/packages/cli/scripts/install.js index 820c7c0f3..f1e1040ee 100644 --- a/packages/cli/scripts/install.js +++ b/packages/cli/scripts/install.js @@ -26,11 +26,11 @@ module.exports = function (installationDir) { manual: 'Manual installation' } } - + const tasks = { installDeps: { title: 'Installing dependencies', - task: () => execa.shell('cd '+ installationDir + ' && yarn') + task: () => execa.shell('cd ' + installationDir + ' && yarn') }, cloneVersion: { title: 'Copying Vue Storefront files', @@ -56,7 +56,7 @@ module.exports = function (installationDir) { }) }, } - + if (fs.existsSync(installationDir)) { console.error('Vue Storefront is already installed in directory ./' + installationDir + '. Aborting.') } else { diff --git a/packages/cli/scripts/manual.js b/packages/cli/scripts/manual.js index 73ef380d6..55b1b8c91 100644 --- a/packages/cli/scripts/manual.js +++ b/packages/cli/scripts/manual.js @@ -1,8 +1,9 @@ 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 available commands') + console.log(' --version CLI version\n') console.log('Commands:') - console.log(' init [dir] setup new VS project') -} \ No newline at end of file + console.log(' init [dir] setup new VS project') + console.log(' init:module [name] generate vs module boilerplate') +} diff --git a/shims.d.ts b/shims.d.ts index eb40980e5..771cb22bd 100644 --- a/shims.d.ts +++ b/shims.d.ts @@ -1,4 +1,12 @@ -declare module "*.vue" { +declare module '*.vue' { import Vue from 'vue' export default Vue } + +declare module 'vue-router' { + import VueRouter, { RouteConfig } from 'node_modules/vue-router' + export * from 'node_modules/vue-router' + export default class extends VueRouter { + public addRoutes (routes: RouteConfig[], useRouteQueue?: boolean, priority?: number): void; + } +} diff --git a/src/extensions/index.ts b/src/extensions/index.ts deleted file mode 100644 index 8519f17dc..000000000 --- a/src/extensions/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * If you have some extensions that are not yet ported to modules you can register them here - * Keep in mind that extensions will be depreciated in next versions of Vue Storefront and replaced by modules - */ -export const registerExtensions = [] diff --git a/src/modules/amp-renderer/index.ts b/src/modules/amp-renderer/index.ts index 0847a04e8..92d659b4a 100644 --- a/src/modules/amp-renderer/index.ts +++ b/src/modules/amp-renderer/index.ts @@ -1,15 +1,16 @@ -import { createModule } from '@vue-storefront/core/lib/module' import moduleRoutes from './router' +import { StorefrontModule } from '@vue-storefront/core/lib/modules' +import { setupMultistoreRoutes } from '@vue-storefront/core/lib/multistore' +import config from 'config' -const store = { +const ampRendererStore = { namespaced: true, state: { key: null } } -const KEY = 'amp-renderer' -export const AmpRenderer = createModule({ - key: KEY, - router: { routes: moduleRoutes }, - store: { modules: [{ key: KEY, module: store }] } -}) + +export const AmpRendererModule: StorefrontModule = function ({store, router}) { + store.registerModule('amp-renderer', ampRendererStore) + setupMultistoreRoutes(config, router, moduleRoutes, 10) +} diff --git a/src/modules/claims/hooks/beforeRegistration.ts b/src/modules/claims/hooks/beforeRegistration.ts deleted file mode 100644 index 968329935..000000000 --- a/src/modules/claims/hooks/beforeRegistration.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as localForage from 'localforage' -import UniversalStorage from '@vue-storefront/core/store/lib/storage' -import { currentStoreView } from '@vue-storefront/core/lib/multistore' - -export function beforeRegistration ({ Vue, config, store, isServer }) { - const storeView = currentStoreView() - const dbNamePrefix = storeView.storeCode ? storeView.storeCode + '-' : '' - - Vue.prototype.$db.claimsCollection = new UniversalStorage(localForage.createInstance({ - name: (config.storeViews.commonCache ? '' : dbNamePrefix) + 'shop', - storeName: 'claims', - driver: localForage[config.localForage.defaultDrivers['claims']] - })) -} diff --git a/src/modules/claims/index.ts b/src/modules/claims/index.ts deleted file mode 100644 index 71068a97f..000000000 --- a/src/modules/claims/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createModule } from '@vue-storefront/core/lib/module' -import { beforeRegistration } from './hooks/beforeRegistration' -import { module } from './store' - -const KEY = 'claims' - -export const Claims = createModule({ - key: KEY, - store: { modules: [{ key: KEY, module }] }, - beforeRegistration -}) diff --git a/src/modules/claims/store/actions.ts b/src/modules/claims/store/actions.ts deleted file mode 100644 index 54180bcfb..000000000 --- a/src/modules/claims/store/actions.ts +++ /dev/null @@ -1,35 +0,0 @@ -import Vue from 'vue' -import { ActionTree } from 'vuex' -import RootState from '@vue-storefront/core/types/RootState' -import ClaimsState from '../types/ClaimsState' -import { Logger } from '@vue-storefront/core/lib/logger' - -const actions: ActionTree = { - set (context, { claimCode, value, description }) { - const claimCollection = Vue.prototype.$db.claimsCollection - claimCollection.setItem(claimCode, { - code: claimCode, - created_at: new Date(), - value: value, - description: description - }).catch((reason) => { - Logger.error(reason) // it doesn't work on SSR - }) - }, - - unset (context, { claimCode }) { - const claimCollection = Vue.prototype.$db.claimsCollection - claimCollection.removeItem(claimCode).catch((reason) => { - Logger.error(reason) // it doesn't work on SSR - }) - }, - - check (context, { claimCode }) { - const claimCollection = Vue.prototype.$db.claimsCollection - return claimCollection.getItem(claimCode).catch((reason) => { - Logger.error(reason) // it doesn't work on SSR - }) - } -} - -export default actions diff --git a/src/modules/claims/store/index.ts b/src/modules/claims/store/index.ts deleted file mode 100644 index 5986f0c10..000000000 --- a/src/modules/claims/store/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from 'vuex' -import actions from './actions' -import RootState from '@vue-storefront/core/types/RootState' -import ClaimsState from '../types/ClaimsState' - -export const module: Module = { - namespaced: true, - actions -} diff --git a/src/modules/claims/types/ClaimsState.ts b/src/modules/claims/types/ClaimsState.ts deleted file mode 100644 index ba5738b7a..000000000 --- a/src/modules/claims/types/ClaimsState.ts +++ /dev/null @@ -1,3 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export default interface ClaimsState { -} diff --git a/src/modules/client.ts b/src/modules/client.ts new file mode 100644 index 000000000..192debde8 --- /dev/null +++ b/src/modules/client.ts @@ -0,0 +1,44 @@ +import { VueStorefrontModule } from '@vue-storefront/core/lib/module' +import { CatalogModule } from '@vue-storefront/core/modules/catalog' +import { CatalogNextModule } from '@vue-storefront/core/modules/catalog-next' +import { CartModule } from '@vue-storefront/core/modules/cart' +import { CheckoutModule } from '@vue-storefront/core/modules/checkout' +import { CompareModule } from '@vue-storefront/core/modules/compare' +import { WishlistModule } from '@vue-storefront/core/modules/wishlist' +import { NotificationModule } from '@vue-storefront/core/modules/notification' +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 { PaymentBackendMethodsModule } from './payment-backend-methods' +import { PaymentCashOnDeliveryModule } from './payment-cash-on-delivery' +import { NewsletterModule } from '@vue-storefront/core/modules/newsletter' + +import { registerModule } from '@vue-storefront/core/lib/modules' + +// TODO:distributed across proper pages BEFORE 1.11 +export function registerClientModules () { + registerModule(UrlModule) + registerModule(CatalogModule) + registerModule(CheckoutModule) // To Checkout + registerModule(CartModule) + registerModule(PaymentBackendMethodsModule) + registerModule(PaymentCashOnDeliveryModule) + registerModule(WishlistModule) // Trigger on wishlist icon click + registerModule(NotificationModule) + registerModule(UserModule) // Trigger on user icon click + registerModule(CatalogNextModule) + registerModule(CompareModule) + registerModule(BreadcrumbsModule) + registerModule(GoogleTagManagerModule) + registerModule(AmpRendererModule) + registerModule(CmsModule) + registerModule(NewsletterModule) +} + +// Deprecated API, will be removed in 2.0 +export const registerModules: VueStorefrontModule[] = [ + // Example +] diff --git a/src/modules/compress/server.ts b/src/modules/compress/server.ts new file mode 100644 index 000000000..3ca3054bc --- /dev/null +++ b/src/modules/compress/server.ts @@ -0,0 +1,9 @@ +import { serverHooks } from '@vue-storefront/core/server/hooks' + +const compression = require('compression') +serverHooks.afterApplicationInitialized(({ app, isProd }) => { + if (isProd) { + console.log('Output Compression is enabled') + app.use(compression({ enabled: isProd })) + } +}) diff --git a/src/modules/droppoint-shipping/components/DroppointMap.vue b/src/modules/droppoint-shipping/components/DroppointMap.vue deleted file mode 100644 index 8d799e115..000000000 --- a/src/modules/droppoint-shipping/components/DroppointMap.vue +++ /dev/null @@ -1,239 +0,0 @@ - - - - - diff --git a/src/modules/droppoint-shipping/index.ts b/src/modules/droppoint-shipping/index.ts deleted file mode 100644 index 2d3050c1d..000000000 --- a/src/modules/droppoint-shipping/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createModule } from '@vue-storefront/core/lib/module' -import { module } from './store' - -export const KEY = 'droppoint-shipping' -export const DroppointShipping = createModule({ - key: KEY, - store: { modules: [{ key: KEY, module }] } -}) diff --git a/src/modules/droppoint-shipping/store/actions.ts b/src/modules/droppoint-shipping/store/actions.ts deleted file mode 100644 index bc96845fb..000000000 --- a/src/modules/droppoint-shipping/store/actions.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ActionTree } from 'vuex'; -import { execute as taskExecute } from '@vue-storefront/core/lib/sync/task' -import * as entities from '@vue-storefront/core/store/lib/entities' - -// actions -export const actions: ActionTree = { - fetch ({ commit }, request) { - const taskId = entities.uniqueEntityId(request) - request.task_id = taskId.toString() - return taskExecute(request) - } -} diff --git a/src/modules/droppoint-shipping/store/index.ts b/src/modules/droppoint-shipping/store/index.ts deleted file mode 100644 index 50bde5e22..000000000 --- a/src/modules/droppoint-shipping/store/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Module } from 'vuex' -import { actions } from './actions' - -export const module: Module = { - namespaced: true, - actions -} diff --git a/src/modules/google-analytics/hooks/afterRegistration.ts b/src/modules/google-analytics/hooks/afterRegistration.ts deleted file mode 100644 index 7d0f29fef..000000000 --- a/src/modules/google-analytics/hooks/afterRegistration.ts +++ /dev/null @@ -1,20 +0,0 @@ -export function afterRegistration ({ Vue, config, store, isServer }) { - if (config.analytics.id && !isServer) { - Vue.prototype.$bus.$on('order-after-placed', event => { - const order = event.order - const ecommerce = (Vue as any).$ga.ecommerce - - order.products.forEach(product => { - ecommerce.addItem({ - id: product.id.toString(), - name: product.name, - sku: product.sku, - category: product.category ? product.category[0].name : '', - price: product.price.toString(), - quantity: product.qty.toString() - }) - }) - ecommerce.send() - }) - } -} diff --git a/src/modules/google-analytics/hooks/beforeRegistration.ts b/src/modules/google-analytics/hooks/beforeRegistration.ts deleted file mode 100644 index 5aae76bca..000000000 --- a/src/modules/google-analytics/hooks/beforeRegistration.ts +++ /dev/null @@ -1,23 +0,0 @@ -import VueAnalytics from 'vue-analytics' -import { router } from '@vue-storefront/core/app' -import { Logger } from '@vue-storefront/core/lib/logger' -import { once } from '@vue-storefront/core/helpers' - -export function beforeRegistration ({ Vue, config, store, isServer }) { - if (config.analytics.id && !isServer) { - once('__VUE_EXTEND_ANALYTICS__', () => { - Vue.use(VueAnalytics, { - id: config.analytics.id, - router, - ecommerce: { - enabled: true - } - }) - }) - } else { - Logger.warn( - 'Google Analytics extension is not working. Ensure Google Analytics account ID is defined in config', - 'GA' - )() - } -} diff --git a/src/modules/google-analytics/index.ts b/src/modules/google-analytics/index.ts index f52b53676..ce7ccd9a8 100644 --- a/src/modules/google-analytics/index.ts +++ b/src/modules/google-analytics/index.ts @@ -1,18 +1,52 @@ -import { createModule } from '@vue-storefront/core/lib/module' -import { beforeRegistration } from './hooks/beforeRegistration' -import { afterRegistration } from './hooks/afterRegistration' +import VueAnalytics from 'vue-analytics' +import { Logger } from '@vue-storefront/core/lib/logger' +import { once, isServer } from '@vue-storefront/core/helpers' +import { StorefrontModule } from '@vue-storefront/core/lib/modules'; +import Vue from 'vue'; -const store = { +const googleAnalyticsStore = { namespaced: true, state: { key: null } } -const KEY = 'google-analytics' -export const GoogleAnalytics = createModule({ - key: KEY, - store: { modules: [{ key: KEY, module: store }] }, - beforeRegistration, - afterRegistration -}) +export const GoogleAnalyticsModule: StorefrontModule = function ({store, router, appConfig}) { + if (appConfig.analytics.id && !isServer) { + once('__VUE_EXTEND_ANALYTICS__', () => { + Vue.use(VueAnalytics, { + id: appConfig.analytics.id, + router, + ecommerce: { + enabled: true + } + }) + }) + } else { + Logger.warn( + 'Google Analytics extension is not working. Ensure Google Analytics account ID is defined in config', + 'GA' + )() + } + + store.registerModule('google-analytics', googleAnalyticsStore) + + if (appConfig.analytics.id && !isServer) { + Vue.prototype.$bus.$on('order-after-placed', event => { + const order = event.order + const ecommerce = (Vue as any).$ga.ecommerce + + order.products.forEach(product => { + ecommerce.addItem({ + id: product.id.toString(), + name: product.name, + sku: product.sku, + category: product.category ? product.category[0].name : '', + price: product.price.toString(), + quantity: product.qty.toString() + }) + }) + ecommerce.send() + }) + } +} diff --git a/src/modules/google-cloud-trace/package.json b/src/modules/google-cloud-trace/package.json new file mode 100644 index 000000000..9f3b9cdbe --- /dev/null +++ b/src/modules/google-cloud-trace/package.json @@ -0,0 +1,10 @@ +{ + "name": "google-cloud-tracing", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "private": true, + "dependencies": { + "@google-cloud/trace-agent": "^4.1.1" + } +} diff --git a/src/modules/google-cloud-trace/server.ts b/src/modules/google-cloud-trace/server.ts new file mode 100644 index 000000000..5e7db50b0 --- /dev/null +++ b/src/modules/google-cloud-trace/server.ts @@ -0,0 +1,8 @@ +import { serverHooks } from '@vue-storefront/core/server/hooks' + +serverHooks.afterProcessStarted((config) => { + let trace = require('@google-cloud/trace-agent') + if (config.has('trace') && config.get('trace.enabled')) { + trace.start(config.get('trace.config')) + } +}) diff --git a/src/modules/google-tag-manager/hooks/afterRegistration.ts b/src/modules/google-tag-manager/hooks/afterRegistration.ts index 5c4230c34..a75e436e6 100644 --- a/src/modules/google-tag-manager/hooks/afterRegistration.ts +++ b/src/modules/google-tag-manager/hooks/afterRegistration.ts @@ -1,21 +1,38 @@ +import Vue from 'vue' +import VueGtm from 'vue-gtm' +import { Store } from 'vuex' import { currentStoreView } from '@vue-storefront/core/lib/multistore' +import { isServer } from '@vue-storefront/core/helpers' + +export const isEnabled = (gtmId: string | null) => { + return typeof gtmId === 'string' && gtmId.length > 0 && !isServer +} + +export function afterRegistration (config, store: Store) { + if (isEnabled(config.googleTagManager.id)) { + const GTM: VueGtm = (Vue as any).gtm -export function afterRegistration ({ Vue, config, store, isServer }) { - if (config.googleTagManager.id && !isServer) { const storeView = currentStoreView() const currencyCode = storeView.i18n.currencyCode const getProduct = (item) => { - const { name, id, sku, priceInclTax: price, category, qty: quantity } = item - let product = { - name, - id, - sku, - price - } - if (quantity) { - product['quantity'] = quantity - } + let product = {} + + const attributeMap: string[]|Record[] = config.googleTagManager.product_attributes + attributeMap.forEach(attribute => { + const isObject = typeof attribute === 'object' + let attributeField = isObject ? Object.keys(attribute)[0] : attribute + let attributeName = isObject ? Object.values(attribute)[0] : attribute + + if (item.hasOwnProperty(attributeField) || product.hasOwnProperty(attributeName)) { + const value = item[attributeField] || product[attributeName] + if (value) { + product[attributeName] = value + } + } + }) + + const { category } = item if (category && category.length > 0) { product['category'] = category.slice(-1)[0].name } @@ -26,7 +43,7 @@ export function afterRegistration ({ Vue, config, store, isServer }) { store.subscribe(({ type, payload }, state) => { // Adding a Product to a Shopping Cart if (type === 'cart/cart/ADD') { - Vue.gtm.trackEvent({ + GTM.trackEvent({ event: 'addToCart', ecommerce: { currencyCode: currencyCode, @@ -39,7 +56,7 @@ export function afterRegistration ({ Vue, config, store, isServer }) { // Removing a Product from a Shopping Cart if (type === 'cart/cart/DEL') { - Vue.gtm.trackEvent({ + GTM.trackEvent({ event: 'removeFromCart', ecommerce: { remove: { @@ -51,7 +68,7 @@ export function afterRegistration ({ Vue, config, store, isServer }) { // Measuring Views of Product Details if (type === 'product/product/SET_PRODUCT_CURRENT') { - Vue.gtm.trackEvent({ + GTM.trackEvent({ ecommerce: { detail: { 'actionField': { 'list': '' }, // 'detail' actions have an optional list property. @@ -72,7 +89,7 @@ export function afterRegistration ({ Vue, config, store, isServer }) { const orderHistory = state.user.orders_history const order = orderHistory.items.find((order) => order['entity_id'].toString() === orderId) if (order) { - Vue.gtm.trackEvent({ + GTM.trackEvent({ 'ecommerce': { 'purchase': { 'actionField': { diff --git a/src/modules/google-tag-manager/hooks/beforeRegistration.ts b/src/modules/google-tag-manager/hooks/beforeRegistration.ts deleted file mode 100644 index 39cb0aa59..000000000 --- a/src/modules/google-tag-manager/hooks/beforeRegistration.ts +++ /dev/null @@ -1,15 +0,0 @@ -import VueGtm from 'vue-gtm'; -import { router } from '@vue-storefront/core/app' -import { Logger } from '@vue-storefront/core/lib/logger' -export function beforeRegistration ({ Vue, config, isServer }) { - if (config.googleTagManager.id && !isServer) { - Vue.use(VueGtm, { - id: config.googleTagManager.id, - enabled: true, - debug: config.googleTagManager.debug, - vueRouter: router - }); - } else { - Logger.warn('Google Tag Manager extensions is not working. Ensure Google Tag Manager container ID is defined in config', 'GTM')() - } -} diff --git a/src/modules/google-tag-manager/index.ts b/src/modules/google-tag-manager/index.ts index c670f216c..b50ab6cf3 100644 --- a/src/modules/google-tag-manager/index.ts +++ b/src/modules/google-tag-manager/index.ts @@ -1,21 +1,30 @@ -import { VueStorefrontModule, VueStorefrontModuleConfig } from '@vue-storefront/core/lib/module' -import { beforeRegistration } from './hooks/beforeRegistration' -import { afterRegistration } from './hooks/afterRegistration' +import Vue from 'vue' +import VueGtm from 'vue-gtm' -const store = { - namespaced: true, - state: { - key: null +import { once, isServer } from '@vue-storefront/core/helpers' +import { StorefrontModule } from '@vue-storefront/core/lib/modules' +import { Logger } from '@vue-storefront/core/lib/logger' + +import { googleTagManagerModule } from './store' +import { afterRegistration, isEnabled } from './hooks/afterRegistration' + +export const KEY = 'google-tag-manager' + +export const GoogleTagManagerModule: StorefrontModule = function ({store, router, appConfig}) { + if (isEnabled(appConfig.googleTagManager.id)) { + once('__VUE_EXTEND_GTM__', () => { + Vue.use(VueGtm, { + enabled: true, + id: appConfig.googleTagManager.id, + debug: appConfig.googleTagManager.debug, + vueRouter: router + }) + }) + } else { + Logger.warn('Google Tag Manager extensions is not working. Ensure Google Tag Manager container ID is defined in config', 'GTM')() } -} -const KEY = 'google-tag-manager' + store.registerModule(KEY, googleTagManagerModule) -const moduleConfig: VueStorefrontModuleConfig = { - key: KEY, - store: { modules: [{ key: KEY, module: store }] }, - beforeRegistration, - afterRegistration + afterRegistration(appConfig, store) } - -export const googleTagManager = new VueStorefrontModule(moduleConfig) diff --git a/src/modules/google-tag-manager/store/index.ts b/src/modules/google-tag-manager/store/index.ts new file mode 100644 index 000000000..5a29ae581 --- /dev/null +++ b/src/modules/google-tag-manager/store/index.ts @@ -0,0 +1,9 @@ +import { Module } from 'vuex' +import GoogleTagManagerState from '../types/GoogleTagManagerState' + +export const googleTagManagerModule: Module = { + namespaced: true, + state: { + key: null + } +} diff --git a/src/modules/google-tag-manager/types/GoogleTagManagerState.ts b/src/modules/google-tag-manager/types/GoogleTagManagerState.ts new file mode 100644 index 000000000..6a6c6e26a --- /dev/null +++ b/src/modules/google-tag-manager/types/GoogleTagManagerState.ts @@ -0,0 +1,3 @@ +export default interface GoogleTagManagerState { + key?: null|string +} diff --git a/src/modules/homepage/index.ts b/src/modules/homepage/index.ts deleted file mode 100644 index aba4587b7..000000000 --- a/src/modules/homepage/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createModule } from '@vue-storefront/core/lib/module' -// TODO: Move the logic to appropriate modules and depreciate this one - -const KEY = 'homepage' -const store = { - namespaced: true, - state: { - new_collection: [] - } -} -export const Homepage = createModule({ - key: KEY, - store: { modules: [{ key: KEY, module: store }] } -}) diff --git a/src/modules/hotjar/hooks/afterRegistration.ts b/src/modules/hotjar/hooks/afterRegistration.ts deleted file mode 100644 index 7d88fa31c..000000000 --- a/src/modules/hotjar/hooks/afterRegistration.ts +++ /dev/null @@ -1,19 +0,0 @@ -const hotjarSnippet = (hjid) => (function (h, o, t, j, a, r) { - h.hj = - h.hj || - function () { - (h.hj.q = h.hj.q || []).push(arguments); - }; - h._hjSettings = {hjid, hjsv: 6}; - a = o.getElementsByTagName('head')[0]; - r = o.createElement('script'); - r.async = 1; - r.src = t + h._hjSettings.hjid + j + h._hjSettings.hjsv; - a.appendChild(r); -})(window as any, document, '//static.hotjar.com/c/hotjar-', '.js?sv='); - -export function afterRegistration ({ Vue, config, store, isServer }) { - if (!isServer && config.hotjar && config.hotjar.id) { - hotjarSnippet(config.hotjar.id); - } -} diff --git a/src/modules/hotjar/index.ts b/src/modules/hotjar/index.ts index 9492527b5..f2aadceab 100644 --- a/src/modules/hotjar/index.ts +++ b/src/modules/hotjar/index.ts @@ -1,15 +1,31 @@ -import { createModule } from '@vue-storefront/core/lib/module' -import { afterRegistration } from './hooks/afterRegistration' +import { StorefrontModule } from '@vue-storefront/core/lib/modules'; +import { isServer } from '@vue-storefront/core/helpers' -const store = { +const hotjarStore = { namespaced: true, state: { key: null } }; -const KEY = 'hotjar'; -export const Hotjar = createModule({ - key: KEY, - store: { modules: [{ key: KEY, module: store }] }, - afterRegistration -}); + +const hotjarSnippet = (hjid) => (function (h, o, t, j, a, r) { + h.hj = + h.hj || + function () { + (h.hj.q = h.hj.q || []).push(arguments); + }; + h._hjSettings = {hjid, hjsv: 6}; + a = o.getElementsByTagName('head')[0]; + r = o.createElement('script'); + r.async = 1; + r.src = t + h._hjSettings.hjid + j + h._hjSettings.hjsv; + a.appendChild(r); +})(window as any, document, '//static.hotjar.com/c/hotjar-', '.js?sv='); + +export const HotjarModule: StorefrontModule = function ({store, appConfig}) { + store.registerModule('hotjar', hotjarStore) + + if (!isServer && appConfig.hotjar && appConfig.hotjar.id) { + hotjarSnippet(appConfig.hotjar.id); + } +} diff --git a/src/modules/index.ts b/src/modules/index.ts deleted file mode 100644 index 27f579a75..000000000 --- a/src/modules/index.ts +++ /dev/null @@ -1,81 +0,0 @@ -// import { extendModule } from '@vue-storefront/core/lib/module' -import { VueStorefrontModule } from '@vue-storefront/core/lib/module' -import { Catalog } from '@vue-storefront/core/modules/catalog' -import { Cart } from '@vue-storefront/core/modules/cart' -import { Checkout } from '@vue-storefront/core/modules/checkout' -import { Compare } from '@vue-storefront/core/modules/compare' -import { Review } from '@vue-storefront/core/modules/review' -import { Mailer } from '@vue-storefront/core/modules/mailer' -import { Wishlist } from '@vue-storefront/core/modules/wishlist' -import { Newsletter } from '@vue-storefront/core/modules/newsletter' -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' -import { Ui } from './ui-store' -// import { GoogleAnalytics } from './google-analytics'; -// import { Hotjar } from './hotjar'; -import { googleTagManager } from './google-tag-manager'; -import { AmpRenderer } from './amp-renderer'; -import { PaymentBackendMethods } from './payment-backend-methods'; -import { PaymentCashOnDelivery } from './payment-cash-on-delivery'; -import { RawOutputExample } from './raw-output-example' -import { InstantCheckout } from './instant-checkout' -import { OrderHistory } from './order-history' - -// import { Example } from './module-template' - -// This is how you can extend any of VS modues -// const extendCartVuex = { -// actions: { -// load () { -// Logger.info('New load function')() -// } -// } -// } - -// const cartExtend = { -// key: 'cart', -// afterRegistration: function(isServer, config) { -// Logger.info('New afterRegistration hook')() -// }, -// store: { modules: [{ key: 'cart', module: extendCartVuex }] }, -// } - -// extendModule(cartExtend) - -/** - * Some of the modules are registered lazily only when components from the module are appearing on current page. - * If you want to use this module in pages without its components you need to remember about registering module first - * In VS 1.8 this modules will be seamlessly lazyLoaded after proper action dispatch - * - Wishlist - */ -export const registerModules: VueStorefrontModule[] = [ - Checkout, - Catalog, - Cart, - Compare, - Review, - Mailer, - Wishlist, - Newsletter, - Notification, - Ui, - RecentlyViewed, - Homepage, - Claims, - PromotedOffers, - googleTagManager, - // GoogleAnalytics, - // Hotjar, - PaymentBackendMethods, - PaymentCashOnDelivery, - RawOutputExample, - AmpRenderer, - InstantCheckout, - Url, - OrderHistory - // Example -] diff --git a/src/modules/instant-checkout/components/InstantCheckout.vue b/src/modules/instant-checkout/components/InstantCheckout.vue index 0fc41e0f8..698718257 100644 --- a/src/modules/instant-checkout/components/InstantCheckout.vue +++ b/src/modules/instant-checkout/components/InstantCheckout.vue @@ -12,10 +12,16 @@ import config from 'config' import i18n from '@vue-storefront/i18n' 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() export default { name: 'InstantCheckoutButton', + beforeCreate () { + registerModule(OrderModule) + }, data () { return { supported: false, @@ -92,7 +98,7 @@ export default { let subtotal = 0 this.$store.state.cart.cartItems.forEach(product => { - subtotal += parseFloat(product.priceInclTax) + subtotal += parseFloat(product.price_incl_tax) }) if (this.selectedShippingOption.length > 0) { @@ -217,12 +223,12 @@ export default { country_id: this.country }, { forceServerSync: true }).then(() => { this.shippingOptions = [] - this.$store.state.shipping.methods.forEach(method => { + this.$store.getters['checkout/getShippingMethods'].forEach(method => { this.shippingOptions.push({ id: method.method_code, carrier_code: method.carrier_code, label: method.method_title, - selected: setDefault ? this.$store.state.shipping.methods[0].method_code === method.method_code : false, + selected: setDefault ? this.$store.getters['checkout/getShippingMethods'][0].method_code === method.method_code : false, amount: { currency: storeView.i18n.currencyCode, value: method.price_incl_tax @@ -292,7 +298,7 @@ export default { }, getProductPrice (product) { if (!config.cart.displayItemDiscounts) { - return product.qty * product.priceInclTax + return product.qty * product.price_incl_tax } if (product.totals) { diff --git a/src/modules/instant-checkout/index.ts b/src/modules/instant-checkout/index.ts index d83cc72fa..d19416d5a 100644 --- a/src/modules/instant-checkout/index.ts +++ b/src/modules/instant-checkout/index.ts @@ -1,9 +1,3 @@ -import { VueStorefrontModule, VueStorefrontModuleConfig } from '@vue-storefront/core/lib/module' +import { StorefrontModule } from '@vue-storefront/core/lib/modules'; -const KEY = 'instant-checkout' - -const moduleConfig: VueStorefrontModuleConfig = { - key: KEY -} - -export const InstantCheckout = new VueStorefrontModule(moduleConfig) +export const InstantCheckoutModule: StorefrontModule = function () {} diff --git a/src/modules/magento-2-cms/README.md b/src/modules/magento-2-cms/README.md deleted file mode 100644 index cf230860a..000000000 --- a/src/modules/magento-2-cms/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# CMS Magento 2 data extension - -To display Cms data: - - install `snowdog/module-cms-api` composer module in your Magento 2 instance, [snowdog/module-cms-api on github](https://github.com/SnowdogApps/magento2-cms-api) - - make sure that in `vue-storefront-api` repo the `cms-data` extension is installed - -## Cms Block -To display Cms Block import CmsData component and use it in template: - -`import CmsData from 'src/modules/magento-2-cms/components/CmsData'` - -we have to options to get Cms Block data: -1. by Magento `identifier`: -`` -where `contact-us-info` is a Cms Block `identifier` from Magento 2 instance - -this option handles different `Store Views` - if multistore is enabled, it takes Cms Block by current Store View, if it's disabled, it set default Store View (`0`) - -2. by Magento id -`` -where `5` is a Magento id of Cms Block. -It doesn't handle differents Store Views so please use it only when multistore it's enabled/ - -## Cms Page -To display Cms Page: - -1. Cms page content like a block -* in custom theme create new page with custom route -* import CmsData component and use it in template: -`import CmsData from '@vue-storefront/extension-magento2-cms/components/CmsData'` - -call Cms Page like a Block using either Magento `identifier`: -`` - -or Magento `id` -`` -where `5` is a cms page identifier from Magento 2 instance - -Like Cms Block, the Cms Page by `identifier` handles different Store Views, Cms Page by `id` handles only Default Store View/ - -2. Cms page content as a page component: -- in custom theme `themes//router/index.js` import `CmsData` component, add custom route and define props: `{identifier: :pageIdentifier, type: 'Page', sync: true}`, example: -``` -import CmsData from '@vue-storefront/extension-magento2-cms/components/CmsData' - -const routes = [ - // ... theme routes - { name: 'cms-page-sync', path: '/cms-page-sync', component: CmsData, props: {identifier: 'about-us', type: 'Page', sync: true} } -] -``` -Complete examples of usage and implementation you can find in Default theme: -1. `/cms-page-sync`, `src/themes/default/router/index.js` diff --git a/src/modules/magento-2-cms/components/CmsData.vue b/src/modules/magento-2-cms/components/CmsData.vue deleted file mode 100644 index 370deaac0..000000000 --- a/src/modules/magento-2-cms/components/CmsData.vue +++ /dev/null @@ -1,82 +0,0 @@ - - - diff --git a/src/modules/magento-2-cms/hooks/afterRegistration.ts b/src/modules/magento-2-cms/hooks/afterRegistration.ts deleted file mode 100644 index 1b7db0435..000000000 --- a/src/modules/magento-2-cms/hooks/afterRegistration.ts +++ /dev/null @@ -1,14 +0,0 @@ -export function afterRegistration ({ Vue, config, store, isServer }) { - store.subscribe((mutation, state) => { - const type = mutation.type - - if ( - type.endsWith('setCmsBlock') || - type.endsWith('setCmsPage') - ) { - Vue.prototype.$db.cmsData.setItem('cms-data', state.cms).catch((reason) => { - console.error(reason) - }) - } - }) -} diff --git a/src/modules/magento-2-cms/index.ts b/src/modules/magento-2-cms/index.ts deleted file mode 100644 index 458e9ffed..000000000 --- a/src/modules/magento-2-cms/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createModule } from '@vue-storefront/core/lib/module' -import { store } from './store' -import { afterRegistration } from './hooks/afterRegistration' - -const KEY = 'cms' -export const Magento2CMS = createModule({ - key: KEY, - store: { modules: [{ key: KEY, module: store }] }, - afterRegistration -}) diff --git a/src/modules/magento-2-cms/store/index.js b/src/modules/magento-2-cms/store/index.js deleted file mode 100644 index e3be87850..000000000 --- a/src/modules/magento-2-cms/store/index.js +++ /dev/null @@ -1,66 +0,0 @@ -import fetch from 'isomorphic-fetch' -import { Logger } from '@vue-storefront/core/lib/logger' -import { processURLAddress } from '@vue-storefront/core/helpers' - -const state = { - cmsPages: [], - cmsBlocks: [] -} - -const getters = { - getBlock: (state) => (id) => { - return state.cmsBlocks.find(item => item.id === id) - }, - getBlockIdentifier: (state) => (identifier) => { - return state.cmsBlocks.find(item => item.identifier === identifier) - }, - getPage: (state) => (id) => { - return state.cmsPages.find(item => item.id === id) - }, - getPageIdentifier: (state) => (identifier) => { - return state.cmsPages.find(item => item.identifier === identifier) - } -} - -// actions -const actions = { - loadCms (context, {url, type}) { - fetch(processURLAddress(url), { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - mode: 'cors' - }) - .then(response => response.json()) - .then(data => { - if (data.code === 200) { - context.commit(`setCms${type}`, data.result) - } - }) - .catch((err) => { - Logger.log(err)() - Logger.error('You need to install a custom Magento module from Snow.dog to make the CMS magic happen. Please go to https://github.com/SnowdogApps/magento2-cms-api and follow the instructions')() - }) - } -} - -// mutations -const mutations = { - setCmsBlock (state, data) { - if (!state.cmsBlocks.filter(e => e.id === data.id).length > 0) { - state.cmsBlocks.push(data) - } - }, - setCmsPage (state, data) { - if (!state.cmsPages.filter(e => e.id === data.id).length > 0) { - state.cmsPages.push(data) - } - } -} - -export const store = { - namespaced: true, - state, - getters, - actions, - mutations -} diff --git a/src/modules/module-template/components/ExtensionComponent.ts b/src/modules/module-template/components/ExtensionComponent.ts deleted file mode 100644 index 85f287fe2..000000000 --- a/src/modules/module-template/components/ExtensionComponent.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * User list component example. Try to document components like this. You can also export .vue files if you want to provide baseline template. - * - * #### Data - * - `users: String[]` - list of users - * - * #### Methods - * - `addUser(name: Function, success?: Function, failure?: Function)` adds new user to the list, calls failure if user with the same name is already on list - */ -export const ExtensionComponent = { - name: 'ExtensionComponent', - computed: { - users () { - return this.$store.state.example.user - } - }, - methods: { - addUser (user: Record, success: (res: Record) => void, failure: (err: Error) => void): void { - this.$store.dispatch('example/addUser', user).then(res => { - success(res) - }).catch(err => { - failure(err) - }) - } - } -} diff --git a/src/modules/module-template/hooks/afterRegistration.ts b/src/modules/module-template/hooks/afterRegistration.ts deleted file mode 100644 index 8179bdb1a..000000000 --- a/src/modules/module-template/hooks/afterRegistration.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 }) { - if (isServer) Logger.info('This will be called after extension registration and only on client side')() -} diff --git a/src/modules/module-template/hooks/beforeRegistration.ts b/src/modules/module-template/hooks/beforeRegistration.ts deleted file mode 100644 index cb495b3a0..000000000 --- a/src/modules/module-template/hooks/beforeRegistration.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 (!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) => { - if (route.name === 'configurable-product') { - store.state.exampleDataFetchedByLoader = 'this is just example data fetched by loader on configurable product page' - } else { - store.state.exampleDataFetchedByLoader = 'this is just example data fetched by loader on any page' - } - resolve(null) - }) - } - }) -} diff --git a/src/modules/module-template/index.ts b/src/modules/module-template/index.ts deleted file mode 100644 index 3c3216bc8..000000000 --- a/src/modules/module-template/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Read more about modules: https://github.com/DivanteLtd/vue-storefront/blob/master/doc/api-modules/about-modules.md -import { module } from './store' -import { plugin } from './store/plugin' -import { beforeRegistration } from './hooks/beforeRegistration' -import { afterRegistration } from './hooks/afterRegistration' -import { createModule } from '@vue-storefront/core/lib/module' -import { beforeEach } from './router/beforeEach' -import { afterEach } from './router/afterEach' -import { initCacheStorage } from '@vue-storefront/core/helpers/initCacheStorage' - -export const KEY = 'example' -export const cacheStorage = initCacheStorage(KEY) -export const Example = createModule({ - key: KEY, - store: { modules: [{ key: KEY, module }], plugin }, - beforeRegistration, - afterRegistration, - router: { beforeEach, afterEach } -} -) diff --git a/src/modules/module-template/pages/ExtensionPage.vue b/src/modules/module-template/pages/ExtensionPage.vue deleted file mode 100644 index 25f7f0299..000000000 --- a/src/modules/module-template/pages/ExtensionPage.vue +++ /dev/null @@ -1,18 +0,0 @@ - - - - - diff --git a/src/modules/module-template/queries/exampleQuery.ts b/src/modules/module-template/queries/exampleQuery.ts deleted file mode 100644 index 3dcc12ab7..000000000 --- a/src/modules/module-template/queries/exampleQuery.ts +++ /dev/null @@ -1,12 +0,0 @@ -// GraphQL and ES queries exposed by this module -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' - -export function exampleQuery (queryText, queryFilter) { - let exampleQuery = new SearchQuery() - - exampleQuery = exampleQuery - .setSearchText(queryText) - .applyFilter(queryFilter) - - return exampleQuery -} diff --git a/src/modules/module-template/router/afterEach.ts b/src/modules/module-template/router/afterEach.ts deleted file mode 100644 index 5d1689035..000000000 --- a/src/modules/module-template/router/afterEach.ts +++ /dev/null @@ -1,8 +0,0 @@ -// This function will be executed after entering each route. -// See https://router.vuejs.org/guide/advanced/navigation-guards.html#global-after-hooks -import { Route } from 'vue-router' -import { Logger } from '@vue-storefront/core/lib/logger' - -export function afterEach (to: Route, from: Route) { - Logger.info(`We have just entered ${to.name} from ${from.name}.`)() -} diff --git a/src/modules/module-template/router/beforeEach.ts b/src/modules/module-template/router/beforeEach.ts deleted file mode 100644 index 325247fe4..000000000 --- a/src/modules/module-template/router/beforeEach.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This function will be executed before entering each route. -// It's important to have 'next()'. It enables navigation to new route. -// See https://router.vuejs.org/guide/advanced/navigation-guards.html#global-guards -import { Route } from 'vue-router' -import { Logger } from '@vue-storefront/core/lib/logger' - -export function beforeEach (to: Route, from: Route, next) { - Logger.info('We are going to visit' + to.name)() - next() -} diff --git a/src/modules/module-template/store/actions.ts b/src/modules/module-template/store/actions.ts deleted file mode 100644 index f5fe08ba0..000000000 --- a/src/modules/module-template/store/actions.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ExampleState } from '../types/ExampleState' -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 '../' - -// 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 loadUsers ({ commit }) { - const userData = cacheStorage.getItem('user') - commit(types.SET_USERS, userData) - return userData - }, - // if you are using cache in your module it's a good practice to allow develoeprs to choose either to use it or not - async addUser ({ commit }, { user, useCache = false }) { - commit(types.ADD_USER, user) - if (useCache) cacheStorage.setItem('user', user) - return user - } -} diff --git a/src/modules/module-template/store/getters.ts b/src/modules/module-template/store/getters.ts deleted file mode 100644 index d04186614..000000000 --- a/src/modules/module-template/store/getters.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { ExampleState } from '../types/ExampleState' -import { GetterTree } from 'vuex'; - -export const getters: GetterTree = {} diff --git a/src/modules/module-template/store/index.ts b/src/modules/module-template/store/index.ts deleted file mode 100644 index 463e2f3ba..000000000 --- a/src/modules/module-template/store/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Module } from 'vuex' -import { ExampleState } from '../types/ExampleState' -import { mutations } from './mutations' -import { getters } from './getters' -import { actions } from './actions' -import { state } from './state' - -export const module: Module = { - namespaced: true, - mutations, - actions, - getters, - state -} diff --git a/src/modules/module-template/store/mutation-types.ts b/src/modules/module-template/store/mutation-types.ts deleted file mode 100644 index 508f944d8..000000000 --- a/src/modules/module-template/store/mutation-types.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const SET_USERS = 'TEMPLATE/SET_USERS' -export const ADD_USER = 'TEMPLATE/SET_USER' diff --git a/src/modules/module-template/store/mutations.ts b/src/modules/module-template/store/mutations.ts deleted file mode 100644 index e837caccc..000000000 --- a/src/modules/module-template/store/mutations.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { MutationTree } from 'vuex' -import * as types from './mutation-types' - -export const mutations: MutationTree = { - [types.SET_USERS] (state, payload) { - state.users = payload - }, - [types.ADD_USER] (state, payload) { - state.users.push(payload) - } -} diff --git a/src/modules/module-template/store/plugin.ts b/src/modules/module-template/store/plugin.ts deleted file mode 100644 index b3e3bd29e..000000000 --- a/src/modules/module-template/store/plugin.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as types from './mutation-types' -import { Logger } from '@vue-storefront/core/lib/logger' - -export function plugin (mutation, state) { - if (types[mutation.type]) { - Logger.info('performed mutation from this store with type' + mutation.type)() - } else { - Logger.info('performed mutation from other store with type' + mutation.type)() - } -} diff --git a/src/modules/module-template/store/state.ts b/src/modules/module-template/store/state.ts deleted file mode 100644 index 3f92fa923..000000000 --- a/src/modules/module-template/store/state.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ExampleState } from '../types/ExampleState' - -export const state: ExampleState = { - users: null -} diff --git a/src/modules/module-template/types/ExampleState.ts b/src/modules/module-template/types/ExampleState.ts deleted file mode 100644 index bb9b5a2bb..000000000 --- a/src/modules/module-template/types/ExampleState.ts +++ /dev/null @@ -1,5 +0,0 @@ -// 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 ExampleState { - users: string[] -} diff --git a/src/modules/order-history/index.ts b/src/modules/order-history/index.ts deleted file mode 100644 index d7c3bd377..000000000 --- a/src/modules/order-history/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createModule } from '@vue-storefront/core/lib/module' - -const KEY = 'order-history' -export const OrderHistory = createModule({ - key: KEY -}) diff --git a/src/modules/payment-backend-methods/hooks/afterRegistration.ts b/src/modules/payment-backend-methods/hooks/afterRegistration.ts deleted file mode 100644 index d0bcf2ff1..000000000 --- a/src/modules/payment-backend-methods/hooks/afterRegistration.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as types from './../store/mutation-types' - -export function afterRegistration ({ Vue, config, store, isServer }) { - let correctPaymentMethod = false - - // Place the order. Payload is empty as we don't have any specific info to add for this payment method '{}' - const placeOrder = () => { - if (correctPaymentMethod) { - Vue.prototype.$bus.$emit('checkout-do-placeOrder', {}) - } - } - - 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) - }) - - Vue.prototype.$bus.$on('checkout-before-placeOrder', placeOrder) - - // Mount the info component when required - Vue.prototype.$bus.$on('checkout-payment-method-changed', (paymentMethodCode) => { - let methods = store.state['payment-backend-methods'].methods - if (methods !== null && methods.find(item => (item.code === paymentMethodCode && item.is_server_method === true))) { - correctPaymentMethod = true - } else { - correctPaymentMethod = false - } - }) - } -} diff --git a/src/modules/payment-backend-methods/index.ts b/src/modules/payment-backend-methods/index.ts index c9fcb923a..30b99085d 100644 --- a/src/modules/payment-backend-methods/index.ts +++ b/src/modules/payment-backend-methods/index.ts @@ -1,8 +1,9 @@ -import { createModule } from '@vue-storefront/core/lib/module' -import { afterRegistration } from './hooks/afterRegistration' import * as types from './store/mutation-types' +import { StorefrontModule } from '@vue-storefront/core/lib/modules'; +import { isServer } from '@vue-storefront/core/helpers' +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' -const store = { +const PaymentBackendMethodsStore = { namespaced: true, state: { methods: null @@ -14,9 +15,34 @@ const store = { } } -const KEY = 'payment-backend-methods' -export const PaymentBackendMethods = createModule({ - key: KEY, - store: { modules: [{ key: KEY, module: store }] }, - afterRegistration -}) +export const PaymentBackendMethodsModule: StorefrontModule = function ({store}) { + store.registerModule('payment-backend-methods', PaymentBackendMethodsStore) + + let correctPaymentMethod = false + + // Place the order. Payload is empty as we don't have any specific info to add for this payment method '{}' + const placeOrder = () => { + if (correctPaymentMethod) { + EventBus.$emit('checkout-do-placeOrder', {}) + } + } + + if (!isServer) { + // Update the methods + EventBus.$on('set-unique-payment-methods', methods => { + store.commit('payment-backend-methods/' + types.SET_BACKEND_PAYMENT_METHODS, methods) + }) + + EventBus.$on('checkout-before-placeOrder', placeOrder) + + // Mount the info component when required + EventBus.$on('checkout-payment-method-changed', (paymentMethodCode) => { + let methods = store.state['payment-backend-methods'].methods + if (methods !== null && methods.find(item => (item.code === paymentMethodCode && item.is_server_method === true))) { + correctPaymentMethod = true + } else { + correctPaymentMethod = false + } + }) + } +} diff --git a/src/modules/payment-cash-on-delivery/hooks/afterRegistration.ts b/src/modules/payment-cash-on-delivery/hooks/afterRegistration.ts deleted file mode 100644 index 830ac9b83..000000000 --- a/src/modules/payment-cash-on-delivery/hooks/afterRegistration.ts +++ /dev/null @@ -1,44 +0,0 @@ -import InfoComponent from '../components/Info.vue' -import rootStore from '@vue-storefront/core/store' - -export function afterRegistration ({ Vue, config, store, isServer }) { - // Place the order. Payload is empty as we don't have any specific info to add for this payment method '{}' - let correctPaymentMethod = false - const placeOrder = () => { - if (correctPaymentMethod) { - Vue.prototype.$bus.$emit('checkout-do-placeOrder', {}) - } - } - // Update the methods - let paymentMethodConfig = { - 'title': 'Cash on delivery', - 'code': 'cashondelivery', - 'cost': 0, - 'costInclTax': 0, - 'default': true, - 'offline': true, - 'is_server_method': false - } - rootStore.dispatch('payment/addMethod', paymentMethodConfig) - if (!isServer) { - Vue.prototype.$bus.$on('checkout-before-placeOrder', placeOrder) - - // Mount the info component when required. - Vue.prototype.$bus.$on('checkout-payment-method-changed', (paymentMethodCode) => { - let methods = store.state['payment-backend-methods'].methods - if (methods) { - let method = methods.find(item => (item.code === paymentMethodCode)) - if (paymentMethodCode === 'cashondelivery' && ((typeof method !== 'undefined' && !method.is_server_method) || typeof method === 'undefined') /* otherwise it could be a `payment-backend-methods` module */) { - correctPaymentMethod = true - - // Dynamically inject a component into the order review section (optional) - const Component = Vue.extend(InfoComponent) - const componentInstance = (new Component()) - componentInstance.$mount('#checkout-order-review-additional') - } else { - correctPaymentMethod = false - } - } - }) - } -} diff --git a/src/modules/payment-cash-on-delivery/index.ts b/src/modules/payment-cash-on-delivery/index.ts index 1188d1286..c7507bb5e 100644 --- a/src/modules/payment-cash-on-delivery/index.ts +++ b/src/modules/payment-cash-on-delivery/index.ts @@ -1,8 +1,59 @@ -import { createModule } from '@vue-storefront/core/lib/module' -import { afterRegistration } from './hooks/afterRegistration' - -const KEY = 'payment-cash-on-delivery' -export const PaymentCashOnDelivery = createModule({ - key: KEY, - afterRegistration -}) +import { StorefrontModule } from '@vue-storefront/core/lib/modules'; +import { isServer } from '@vue-storefront/core/helpers' +import Vue from 'vue'; +import InfoComponent from './components/Info.vue' +import EventBus from '@vue-storefront/core/compatibility/plugins/event-bus' + +export const PaymentCashOnDeliveryModule: StorefrontModule = function ({store}) { + // Place the order. Payload is empty as we don't have any specific info to add for this payment method '{}' + let correctPaymentMethod = false + const placeOrder = () => { + if (correctPaymentMethod) { + EventBus.$emit('checkout-do-placeOrder', {}) + } + } + // Update the methods + let paymentMethodConfig = { + 'title': 'Cash on delivery', + 'code': 'cashondelivery', + 'cost': 0, + 'costInclTax': 0, + 'default': true, + 'offline': true, + 'is_server_method': false + } + store.dispatch('checkout/addPaymentMethod', paymentMethodConfig) + if (!isServer) { + // Update the methods + let paymentMethodConfig = { + 'title': 'Cash on delivery', + 'code': 'cashondelivery', + 'cost': 0, + 'cost_incl_tax': 0, + 'default': true, + 'offline': true, + 'is_server_method': false + } + store.dispatch('checkout/addPaymentMethod', paymentMethodConfig) + + EventBus.$on('checkout-before-placeOrder', placeOrder) + + // Mount the info component when required. + EventBus.$on('checkout-payment-method-changed', (paymentMethodCode) => { + let methods = store.state['payment-backend-methods'].methods + if (methods) { + let method = methods.find(item => (item.code === paymentMethodCode)) + if (paymentMethodCode === 'cashondelivery' && ((typeof method !== 'undefined' && !method.is_server_method) || typeof method === 'undefined') /* otherwise it could be a `payment-backend-methods` module */) { + correctPaymentMethod = true + + // Dynamically inject a component into the order review section (optional) + const Component = Vue.extend(InfoComponent) + const componentInstance = (new Component()) + componentInstance.$mount('#checkout-order-review-additional') + } else { + correctPaymentMethod = false + } + } + }) + } +} diff --git a/src/modules/promoted-offers/index.ts b/src/modules/promoted-offers/index.ts deleted file mode 100644 index 15b73399f..000000000 --- a/src/modules/promoted-offers/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createModule } from '@vue-storefront/core/lib/module' -import { module } from './store' - -const KEY = 'promoted' -export const PromotedOffers = createModule({ - key: KEY, - store: { modules: [{ key: KEY, module }] } -}) diff --git a/src/modules/promoted-offers/store/actions.ts b/src/modules/promoted-offers/store/actions.ts deleted file mode 100644 index 5d25b6d72..000000000 --- a/src/modules/promoted-offers/store/actions.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { ActionTree } from 'vuex' -import RootState from '@vue-storefront/core/types/RootState' -import PromotedOffersState from '../types/PromotedOffersState' -import { Logger } from '@vue-storefront/core/lib/logger' - -const actions: ActionTree = { - async updatePromotedOffers ({commit, rootState}, data) { - let promotedBannersResource = rootState.storeView && rootState.storeView.storeCode ? `banners/${rootState.storeView.storeCode}_promoted_offers` : `promoted_offers` - try { - const promotedOffersModule = await import(/* webpackChunkName: "vsf-promoted-offers-[request]" */ 'theme/resource/' + promotedBannersResource + '.json') - commit('updatePromotedOffers', promotedOffersModule) - } catch (err) { - Logger.debug('Unable to load promotedOffers' + err)() - } - }, - async updateHeadImage ({commit, rootState}, data) { - let mainImageResource = rootState.storeView && rootState.storeView.storeCode ? `banners/${rootState.storeView.storeCode}_main-image` : `main-image` - try { - const imageModule = await import(/* webpackChunkName: "vsf-head-img-[request]" */ 'theme/resource/' + mainImageResource + '.json') - commit('SET_HEAD_IMAGE', imageModule.image) - } catch (err) { - Logger.debug('Unable to load headImage' + err)() - } - } -} - -export default actions diff --git a/src/modules/promoted-offers/store/getters.ts b/src/modules/promoted-offers/store/getters.ts deleted file mode 100644 index 8e9f74ac1..000000000 --- a/src/modules/promoted-offers/store/getters.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { GetterTree } from 'vuex' -import RootState from '@vue-storefront/core/types/RootState' -import PromotedOffersState from '../types/PromotedOffersState' - -const getters: GetterTree = { - getPromotedOffers: state => { - return state.banners - }, - getHeadImage: state => state.headImage -} - -export default getters diff --git a/src/modules/promoted-offers/store/index.ts b/src/modules/promoted-offers/store/index.ts deleted file mode 100644 index 83bf36f51..000000000 --- a/src/modules/promoted-offers/store/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Module } from 'vuex' -import getters from './getters' -import actions from './actions' -import mutations from './mutations' -import RootState from '@vue-storefront/core/types/RootState' -import PromotedOffersState from '../types/PromotedOffersState' - -export const module: Module = { - namespaced: true, - state: { - banners: { - mainBanners: [], - smallBanners: [], - productBanners: [] - }, - headImage: null - }, - getters, - actions, - mutations -} diff --git a/src/modules/promoted-offers/store/mutations.ts b/src/modules/promoted-offers/store/mutations.ts deleted file mode 100644 index acb3864d5..000000000 --- a/src/modules/promoted-offers/store/mutations.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { MutationTree } from 'vuex' -import PromotedOffersState from '../types/PromotedOffersState' - -const mutations: MutationTree = { - updatePromotedOffers (state, data) { - state.banners = data - }, - SET_HEAD_IMAGE (state, headImage) { - state.headImage = headImage - } -} - -export default mutations diff --git a/src/modules/promoted-offers/types/PromotedOffersState.ts b/src/modules/promoted-offers/types/PromotedOffersState.ts deleted file mode 100644 index ed2d5664a..000000000 --- a/src/modules/promoted-offers/types/PromotedOffersState.ts +++ /dev/null @@ -1,8 +0,0 @@ -export default interface PromotedOffersState { - banners: { - mainBanners: any[], - smallBanners: any[], - productBanners: any[] - }, - headImage: Record -} diff --git a/src/modules/raw-output-example/index.ts b/src/modules/raw-output-example/index.ts deleted file mode 100644 index 62dc30c83..000000000 --- a/src/modules/raw-output-example/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createModule } from '@vue-storefront/core/lib/module' -import { routes } from './router/routes' - -const KEY = 'raw-output-example' -export const RawOutputExample = createModule({ - key: KEY, - router: { routes } -}) diff --git a/src/modules/raw-output-example/pages/NoJSExample.vue b/src/modules/raw-output-example/pages/NoJSExample.vue deleted file mode 100755 index 1e09d7088..000000000 --- a/src/modules/raw-output-example/pages/NoJSExample.vue +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - diff --git a/src/modules/raw-output-example/pages/NoLayoutAppendPrependExample.vue b/src/modules/raw-output-example/pages/NoLayoutAppendPrependExample.vue deleted file mode 100755 index ec0ed77d9..000000000 --- a/src/modules/raw-output-example/pages/NoLayoutAppendPrependExample.vue +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - diff --git a/src/modules/raw-output-example/pages/RawOutputExample.vue b/src/modules/raw-output-example/pages/RawOutputExample.vue deleted file mode 100755 index 5033610f6..000000000 --- a/src/modules/raw-output-example/pages/RawOutputExample.vue +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - diff --git a/src/modules/raw-output-example/router/routes.ts b/src/modules/raw-output-example/router/routes.ts deleted file mode 100644 index dc935d3cb..000000000 --- a/src/modules/raw-output-example/router/routes.ts +++ /dev/null @@ -1,9 +0,0 @@ -import NoJSExample from '../pages/NoJSExample.vue' -import RawOutputExample from '../pages/RawOutputExample.vue' -import NoLayoutAppendPrependExample from '../pages/NoLayoutAppendPrependExample.vue' - -export const routes = [ - { path: '/raw-output-example.xml', component: RawOutputExample, meta: { layout: 'empty' } }, - { path: '/append-prepend.html', component: NoLayoutAppendPrependExample, meta: { layout: 'empty' } }, - { path: '/no-js.html', component: NoJSExample, meta: { layout: 'default' } } -] diff --git a/src/modules/robots/server.ts b/src/modules/robots/server.ts new file mode 100644 index 000000000..f245ca124 --- /dev/null +++ b/src/modules/robots/server.ts @@ -0,0 +1,7 @@ +import { serverHooks } from '@vue-storefront/core/server/hooks' + +serverHooks.afterApplicationInitialized(({ app }) => { + app.get('/robots.txt', (req, res) => { + res.end('User-agent: *\nDisallow: ') + }) +}) diff --git a/src/modules/sample-custom-entity-graphql/hooks/afterRegistration.ts b/src/modules/sample-custom-entity-graphql/hooks/afterRegistration.ts deleted file mode 100644 index b4066b6bb..000000000 --- a/src/modules/sample-custom-entity-graphql/hooks/afterRegistration.ts +++ /dev/null @@ -1,63 +0,0 @@ - -import { getSearchAdapter } from '@vue-storefront/core/lib/search/adapter/searchAdapterFactory' -import { processESResponseType } from '@vue-storefront/core/lib/search/adapter/graphql/processor/processType' -import { currentStoreView } from '@vue-storefront/core/lib/multistore' -import SearchQuery from '@vue-storefront/core/lib/search/searchQuery' -import { Logger } from '@vue-storefront/core/lib/logger' - -const EXTENSION_KEY = 'sample-custom-entity-graphql-extension' -const TEST_ENTITY_TYPE = 'testentity' - -export function afterRegistration ({ Vue, config, store, isServer }) { - Vue.prototype.$bus.$on('application-after-init', async () => { - Logger.debug('Example of custom entity graphql extension')() - - // create GraphQL searchAdapter - let searchAdapter = await getSearchAdapter('graphql') - - // register custom entity type using registerEntityTypeByQuery - // different GraphQL servers could be used for different entity types - // resolver for testentity should be implemented on the GraphQL server provided - searchAdapter.registerEntityTypeByQuery(TEST_ENTITY_TYPE, { - url: 'http://localhost:8080/graphql/', - query: require('../queries/testentity.gql'), - queryProcessor: (query) => { - // function that can modify the query each time before it's being executed - return query - }, - resultPorcessor: (resp, start, size) => { - if (resp === null) { - throw new Error('Invalid GraphQL result - null not expected') - } - if (resp.hasOwnProperty('data')) { - return processESResponseType(resp.data.testentity, start, size) - } else { - if (resp.error) { - throw new Error(JSON.stringify(resp.error)) - } else { - throw new Error('Unknown error with GraphQL result in resultProcessor for entity type \'category\'') - } - } - } - }) - - const storeView = currentStoreView() - - // create an empty SearchQuery to get all data for the new custom entity - const searchQuery = new SearchQuery() - - // prepare a SearchRequest object - const Request = { - store: storeView.storeCode, // TODO: add grouped product and bundled product support - type: TEST_ENTITY_TYPE, - searchQuery: searchQuery, - sort: '' - } - - // apply test search - searchAdapter.search(Request).then((resp) => { // we're always trying to populate cache - when online - const res = searchAdapter.entities[Request.type].resultPorcessor(resp, 0, 200) - Logger.log('Testentity response: ', res)() - }) - }) -} diff --git a/src/modules/sample-custom-entity-graphql/index.ts b/src/modules/sample-custom-entity-graphql/index.ts deleted file mode 100644 index 60a4d86a6..000000000 --- a/src/modules/sample-custom-entity-graphql/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createModule } from '@vue-storefront/core/lib/module' -import { afterRegistration } from './hooks/afterRegistration' - -const KEY = 'sample-custom-entity-gql' -export const SampleCustomEntityGql = createModule({ - key: KEY, - afterRegistration -}) diff --git a/src/modules/sample-custom-entity-graphql/queries/testentity.gql b/src/modules/sample-custom-entity-graphql/queries/testentity.gql deleted file mode 100644 index 9b23dff99..000000000 --- a/src/modules/sample-custom-entity-graphql/queries/testentity.gql +++ /dev/null @@ -1,8 +0,0 @@ -query testentity ($filter: TestInput) { - testentity( - filter: $filter - ) - { - hits - } -} \ No newline at end of file diff --git a/src/search/adapter/.gitkeep b/src/search/adapter/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/src/server/index.js b/src/server/index.js deleted file mode 100644 index 916823866..000000000 --- a/src/server/index.js +++ /dev/null @@ -1,17 +0,0 @@ -// You can extend Vue Storefront server routes by binding to the Express.js (expressApp) in here -module.exports.registerUserServerRoutes = (expressApp) => { - require('./robots')(expressApp) -} - -// Use can use dynamic config by using this function below: -// (Needs to return a Promise) -// module.exports.configProvider = (req) => { -// const axios = require('axios') -// return new Promise((resolve, reject) => axios.get('myapi.com/config', { -// params: { -// domain: req.headers.host -// } -// }).then(res => { -// resolve(res.data) -// }).catch(error => reject(error))) -// } diff --git a/src/server/robots.js b/src/server/robots.js deleted file mode 100644 index 6d030b3af..000000000 --- a/src/server/robots.js +++ /dev/null @@ -1,6 +0,0 @@ - -module.exports = (expressApp) => { - expressApp.get('/robots.txt', (req, res) => { - res.end('User-agent: *\nDisallow: ') - }) -} diff --git a/src/themes/default-amp/components/core/Header.vue b/src/themes/default-amp/components/core/Header.vue index 29cfa03e6..62202c98e 100755 --- a/src/themes/default-amp/components/core/Header.vue +++ b/src/themes/default-amp/components/core/Header.vue @@ -23,7 +23,10 @@
- + {{ $t('Return to shopping') }}
@@ -33,12 +36,14 @@
- - {{ $t('Login to your account') }} - - - {{ $t('You are logged in as') }} {{ currentUser.firstname }} - + {{ $t('Login to your account') }} + + {{ $t('You are logged in as {firstname}', currentUser) }}
@@ -79,9 +84,13 @@ export default { }) }, beforeMount () { - window.addEventListener('scroll', () => { - this.isScrolling = true - }, {passive: true}) + window.addEventListener( + 'scroll', + () => { + this.isScrolling = true + }, + { passive: true } + ) setInterval(() => { if (this.isScrolling) { @@ -96,7 +105,10 @@ export default { }, hasScrolled () { this.scrollTop = window.scrollY - if (this.scrollTop > this.lastScrollTop && this.scrollTop > this.navbarHeight) { + if ( + this.scrollTop > this.lastScrollTop && + this.scrollTop > this.navbarHeight + ) { this.navVisible = false } else { this.navVisible = true @@ -115,7 +127,7 @@ $color-icon-hover: color(secondary, $colors-background); header { height: 54px; top: -55px; - z-index: 2; + z-index: 3; transition: top 0.2s ease-in-out; &.is-visible { top: 0; @@ -148,12 +160,13 @@ header { } } .col-xs-2:first-of-type { - padding-left: 0; + padding-left: 0; } .col-xs-2:last-of-type { - padding-right: 0; + padding-right: 0; } - a, span { + a, + span { font-size: 12px; } } diff --git a/src/themes/default-amp/components/core/ProductTile.vue b/src/themes/default-amp/components/core/ProductTile.vue index 97ed4e9b7..deb9dbde2 100755 --- a/src/themes/default-amp/components/core/ProductTile.vue +++ b/src/themes/default-amp/components/core/ProductTile.vue @@ -26,23 +26,23 @@ - {{ product.originalPriceInclTax | price }} + {{ product.original_price_incl_tax | price }} - {{ product.priceInclTax | price }} + {{ product.price_incl_tax | price }} - {{ product.priceInclTax | price }} + {{ product.price_incl_tax | price }}
@@ -73,7 +73,7 @@ export default { }, visibilityChanged (isVisible, entry) { if (isVisible) { - if (config.products.configurableChildrenStockPrefetchDynamic && rootStore.products.filterUnavailableVariants) { + if (config.products.configurableChildrenStockPrefetchDynamic && config.products.filterUnavailableVariants) { const skus = [this.product.sku] if (this.product.type_id === 'configurable' && this.product.configurable_children && this.product.configurable_children.length > 0) { for (const confChild of this.product.configurable_children) { diff --git a/src/themes/default-amp/css/base/_base.scss b/src/themes/default-amp/css/base/_base.scss index 1a079fa17..3ee23a4b0 100755 --- a/src/themes/default-amp/css/base/_base.scss +++ b/src/themes/default-amp/css/base/_base.scss @@ -19,7 +19,3 @@ a { #viewport { overflow-x: hidden; } - -.no-scroll { - overflow: hidden; -} diff --git a/src/themes/default-amp/css/utilities/_visibility.scss b/src/themes/default-amp/css/utilities/_visibility.scss index fb4efbd36..9cc02787f 100755 --- a/src/themes/default-amp/css/utilities/_visibility.scss +++ b/src/themes/default-amp/css/utilities/_visibility.scss @@ -6,13 +6,13 @@ .hidden-xs { display: none; - @media only screen and (min-width:48em) { + @media only screen and (min-width:768px) { display: inherit } } .hidden-md { - @media only screen and (min-width:48em) { + @media only screen and (min-width:768px) { display: none; } } diff --git a/src/themes/default-amp/index.js b/src/themes/default-amp/index.js index 0a5b39100..36b484635 100755 --- a/src/themes/default-amp/index.js +++ b/src/themes/default-amp/index.js @@ -1,13 +1,11 @@ import { setupMultistoreRoutes } from '@vue-storefront/core/lib/multistore' -import { RouterManager } from '@vue-storefront/core/lib/router-manager' import config from 'config' import routes from './router' export default function (app, router, store) { - // if youre' runing multistore setup this is copying the routed above adding the 'storeCode' prefix to the urls and the names of the routes + // 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 execlude the desired storeView from the config.storeViews.mapStoreUrlsFor and map the urls by Your own like: + // 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) - RouterManager.addRoutes(routes, router) + setupMultistoreRoutes(config, router, routes, 10) } diff --git a/src/themes/default-amp/package.json b/src/themes/default-amp/package.json index 651f6282f..846459005 100755 --- a/src/themes/default-amp/package.json +++ b/src/themes/default-amp/package.json @@ -1,6 +1,6 @@ { "name": "@vue-storefront/theme-default-amp", - "version": "1.10.5", + "version": "1.11.0", "description": "Default AMP theme for Vue Storefront", "main": "index.js", "scripts": { diff --git a/src/themes/default-amp/pages/Category.vue b/src/themes/default-amp/pages/Category.vue index ff3304cfa..d9143413d 100755 --- a/src/themes/default-amp/pages/Category.vue +++ b/src/themes/default-amp/pages/Category.vue @@ -2,7 +2,7 @@
- +

{{ category.name }} @@ -30,11 +30,9 @@ diff --git a/src/themes/default-amp/router/index.ts b/src/themes/default-amp/router/index.ts index e3c188ed8..ce291ac22 100755 --- a/src/themes/default-amp/router/index.ts +++ b/src/themes/default-amp/router/index.ts @@ -3,7 +3,6 @@ import Category from '../pages/Category.vue' import Product from '../pages/Product.vue' import { RouteConfig } from 'vue-router' -import config from 'config' 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 diff --git a/src/themes/default-amp/webpack.config.js b/src/themes/default-amp/webpack.config.js index f31eabdac..4aea92873 100755 --- a/src/themes/default-amp/webpack.config.js +++ b/src/themes/default-amp/webpack.config.js @@ -1,4 +1,4 @@ -// You can extend default webpack build here. Read more on docs: https://github.com/DivanteLtd/vue-storefront/blob/master/doc/Working%20with%20webpack.md +// 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/components/core/AddToCart.vue b/src/themes/default/components/core/AddToCart.vue index 726ecd595..24ff8c7a7 100644 --- a/src/themes/default/components/core/AddToCart.vue +++ b/src/themes/default/components/core/AddToCart.vue @@ -6,23 +6,46 @@ diff --git a/src/themes/default/components/core/ColorSelector.vue b/src/themes/default/components/core/ColorSelector.vue index 9883c0c12..7b9669c04 100644 --- a/src/themes/default/components/core/ColorSelector.vue +++ b/src/themes/default/components/core/ColorSelector.vue @@ -1,23 +1,25 @@ diff --git a/src/themes/default/components/core/Logo.vue b/src/themes/default/components/core/Logo.vue index 9dba29558..a9c774b37 100644 --- a/src/themes/default/components/core/Logo.vue +++ b/src/themes/default/components/core/Logo.vue @@ -4,13 +4,22 @@ :width="width" :height="height" src="/assets/logo.svg" - alt="Vuestore logo" + :alt="$t(defaultTitle)" > - diff --git a/src/themes/default/components/core/NewsletterPopup.vue b/src/themes/default/components/core/NewsletterPopup.vue index 4724951fb..a96893b7b 100644 --- a/src/themes/default/components/core/NewsletterPopup.vue +++ b/src/themes/default/components/core/NewsletterPopup.vue @@ -59,12 +59,15 @@ export default { this.$off('validation-error') }, methods: { - onSuccesfulSubmission () { - this.$store.dispatch('notification/spawnNotification', { - type: 'success', - message: i18n.t('You have been successfully subscribed to our newsletter!'), - action1: { label: i18n.t('OK') } - }) + onSuccesfulSubmission (isSubscribed) { + if (isSubscribed) { + this.$store.dispatch('notification/spawnNotification', { + type: 'success', + message: i18n.t('You have been successfully subscribed to our newsletter!'), + action1: { label: i18n.t('OK') } + }) + } + this.$bus.$emit('modal-hide', 'modal-newsletter') } }, diff --git a/src/themes/default/components/core/Overlay.vue b/src/themes/default/components/core/Overlay.vue index 6535de7d7..17c6d90ff 100644 --- a/src/themes/default/components/core/Overlay.vue +++ b/src/themes/default/components/core/Overlay.vue @@ -7,12 +7,6 @@ import Overlay from '@vue-storefront/core/compatibility/components/Overlay' export default { mixins: [Overlay], - beforeCreate () { - document.documentElement.classList.add('no-scroll') - }, - destroyed () { - document.documentElement.classList.remove('no-scroll') - }, methods: { close () { this.$store.commit('ui/setOverlay', false) @@ -20,6 +14,7 @@ export default { this.$store.commit('ui/setWishlist', false) this.$store.commit('ui/setSearchpanel', false) this.$store.commit('ui/setSidebar', false) + this.$store.dispatch('themeCart/closeEditMode') } } } diff --git a/src/themes/default/components/core/PriceSelector.vue b/src/themes/default/components/core/PriceSelector.vue index de583ca0c..c9f804c9c 100644 --- a/src/themes/default/components/core/PriceSelector.vue +++ b/src/themes/default/components/core/PriceSelector.vue @@ -1,21 +1,21 @@ diff --git a/src/themes/default/components/core/ProductBundleOptions.vue b/src/themes/default/components/core/ProductBundleOptions.vue index 3d12c8a8e..d36f910e3 100644 --- a/src/themes/default/components/core/ProductBundleOptions.vue +++ b/src/themes/default/components/core/ProductBundleOptions.vue @@ -1,7 +1,7 @@ diff --git a/src/themes/default/components/core/ProductCustomOptions.vue b/src/themes/default/components/core/ProductCustomOptions.vue index db37ef0ce..7a59caeb3 100644 --- a/src/themes/default/components/core/ProductCustomOptions.vue +++ b/src/themes/default/components/core/ProductCustomOptions.vue @@ -18,7 +18,7 @@ >
diff --git a/src/themes/default/components/core/ProductGalleryCarousel.vue b/src/themes/default/components/core/ProductGalleryCarousel.vue index 727d20b41..5372385fd 100644 --- a/src/themes/default/components/core/ProductGalleryCarousel.vue +++ b/src/themes/default/components/core/ProductGalleryCarousel.vue @@ -23,8 +23,7 @@ @@ -67,7 +66,7 @@ export default { }, productName: { type: String, - required: true + default: '' }, configuration: { type: Object, @@ -121,6 +120,8 @@ export default { this.navigate(index) } } + + this.$emit('close') }, openOverlay () { const currentSlide = this.$refs.carousel.currentPage @@ -160,17 +161,13 @@ export default { bottom: 0; right: 0; } -.product-image{ - mix-blend-mode: multiply; +.image{ opacity: 1; - will-change: transform; + will-change: opacity; transition: .3s opacity $motion-main; &:hover{ opacity: .9; } - &--video{ - padding-bottom: calc(319% / (568 / 100)); - } } .video-container { align-items: center; diff --git a/src/themes/default/components/core/ProductGalleryZoomCarousel.vue b/src/themes/default/components/core/ProductGalleryZoomCarousel.vue index 4abd539a0..12ba7d624 100644 --- a/src/themes/default/components/core/ProductGalleryZoomCarousel.vue +++ b/src/themes/default/components/core/ProductGalleryZoomCarousel.vue @@ -1,10 +1,9 @@ diff --git a/src/themes/default/components/core/SortBy.vue b/src/themes/default/components/core/SortBy.vue index b7be3116e..ddec54e72 100644 --- a/src/themes/default/components/core/SortBy.vue +++ b/src/themes/default/components/core/SortBy.vue @@ -9,8 +9,8 @@ -
@@ -18,6 +18,7 @@ + + diff --git a/src/themes/default/components/core/blocks/Auth/ForgotPass.vue b/src/themes/default/components/core/blocks/Auth/ForgotPass.vue index f471af51c..ec8b98f23 100644 --- a/src/themes/default/components/core/blocks/Auth/ForgotPass.vue +++ b/src/themes/default/components/core/blocks/Auth/ForgotPass.vue @@ -1,17 +1,17 @@ diff --git a/src/themes/default/components/core/blocks/Header/MinimalHeader.vue b/src/themes/default/components/core/blocks/Header/MinimalHeader.vue index 8b9324368..d44c5537a 100644 --- a/src/themes/default/components/core/blocks/Header/MinimalHeader.vue +++ b/src/themes/default/components/core/blocks/Header/MinimalHeader.vue @@ -9,7 +9,11 @@
@@ -19,12 +23,7 @@ @@ -41,8 +40,16 @@ @@ -55,7 +62,7 @@ $color-icon-hover: color(secondary, $colors-background); header { height: 54px; top: -55px; - z-index: 2; + z-index: 3; transition: top 0.2s ease-in-out; &.is-visible { top: 0; @@ -88,12 +95,13 @@ header { } } .col-xs-2:first-of-type { - padding-left: 0; + padding-left: 0; } .col-xs-2:last-of-type { - padding-right: 0; + padding-right: 0; } - a, span { + a, + span { font-size: 12px; } } diff --git a/src/themes/default/components/core/blocks/Header/WishlistIcon.vue b/src/themes/default/components/core/blocks/Header/WishlistIcon.vue index df5c2bc4c..d299a9b71 100644 --- a/src/themes/default/components/core/blocks/Header/WishlistIcon.vue +++ b/src/themes/default/components/core/blocks/Header/WishlistIcon.vue @@ -1,11 +1,18 @@ @@ -16,3 +23,13 @@ export default { mixins: [WishlistIcon] } + + diff --git a/src/themes/default/components/core/blocks/Microcart/EditButton.vue b/src/themes/default/components/core/blocks/Microcart/EditButton.vue new file mode 100644 index 000000000..ad2198838 --- /dev/null +++ b/src/themes/default/components/core/blocks/Microcart/EditButton.vue @@ -0,0 +1,8 @@ + diff --git a/src/themes/default/components/core/blocks/Microcart/EditMode.vue b/src/themes/default/components/core/blocks/Microcart/EditMode.vue new file mode 100644 index 000000000..41ee04bb9 --- /dev/null +++ b/src/themes/default/components/core/blocks/Microcart/EditMode.vue @@ -0,0 +1,48 @@ + diff --git a/src/themes/default/components/core/blocks/Microcart/Microcart.vue b/src/themes/default/components/core/blocks/Microcart/Microcart.vue index 771906f8f..fdb3d257b 100644 --- a/src/themes/default/components/core/blocks/Microcart/Microcart.vue +++ b/src/themes/default/components/core/blocks/Microcart/Microcart.vue @@ -1,9 +1,12 @@ diff --git a/src/themes/default/components/core/blocks/Microcart/RemoveButton.vue b/src/themes/default/components/core/blocks/Microcart/RemoveButton.vue index 7708df067..d01ee3545 100644 --- a/src/themes/default/components/core/blocks/Microcart/RemoveButton.vue +++ b/src/themes/default/components/core/blocks/Microcart/RemoveButton.vue @@ -1,5 +1,5 @@ @@ -160,6 +164,7 @@ export default { overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; + .close-icon-row { display: flex; justify-content: flex-end; diff --git a/src/themes/default/components/core/blocks/SidebarMenu/SidebarMenu.vue b/src/themes/default/components/core/blocks/SidebarMenu/SidebarMenu.vue index e2d6af05f..b2249d680 100644 --- a/src/themes/default/components/core/blocks/SidebarMenu/SidebarMenu.vue +++ b/src/themes/default/components/core/blocks/SidebarMenu/SidebarMenu.vue @@ -18,7 +18,7 @@
-