diff --git a/.eslintrc.js b/.eslintrc.js index 2d98134c4d..3697d18fb2 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/CHANGELOG.md b/CHANGELOG.md index 26c6f2bc5a..1aae462ee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,43 @@ 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-rc.2] - UNRELEASED +## [1.11.0-rc.2] - 2019.10.31 ### 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) +### Added +- 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 48784e9536..00130e0de7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,10 +35,8 @@ Here are some thoughts on how to use TypeScript features in Vue Storefront: [Typ ## Pull Request Checklist -Here's how to submit a pull request. **Pull request that don't meet these requirements will not be merged.** - -**ALWAYS** use the [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 +**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 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,7 +44,7 @@ Here's how to submit a pull request. **Pull request that don't meet these requir 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)) @@ -54,5 +52,5 @@ Here's how to submit a pull request. **Pull request that don't meet these requir Your pull request will be merged after meeting following criteria: - Everything from "Pull Request Checklist" -- Pull request is proposed to appropriate branch +- PR is proposed to appropriate branch - There are at least two approvals from core team members diff --git a/config/default.json b/config/default.json index d917d5e340..d1b0ba8ed1 100644 --- a/config/default.json +++ b/config/default.json @@ -183,15 +183,15 @@ "validSearchOptionsFromRouteParams": ["url-key", "slug", "id"] }, "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" ] + "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": [ "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"], + "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": [ "type_id", "sku", "name", "tax_class_id", "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"], + "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": { @@ -311,6 +311,7 @@ "setupVariantByAttributeCode": true, "endpoint": "/api/product", "defaultFilters": ["color", "size", "price", "erin_recommends"], + "systemFilterNames": ["sort"], "maxFiltersQuerySize": 999, "routerFiltersSource": "query", "filterFieldMapping": { diff --git a/core/build/webpack.base.config.ts b/core/build/webpack.base.config.ts index 9f3f642a23..1cf00cb3eb 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'; @@ -60,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 diff --git a/core/build/webpack.prod.sw.config.ts b/core/build/webpack.prod.sw.config.ts index 03dd0e38f4..64f90aa020 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', diff --git a/core/i18n/helpers.ts b/core/i18n/helpers.ts new file mode 100644 index 0000000000..d8e3d17ad9 --- /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 e785373eb6..500b45aa51 100644 --- a/core/i18n/index.ts +++ b/core/i18n/index.ts @@ -28,12 +28,12 @@ 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 - await import(/* webpackChunkName: "dayjs-locales" */ `dayjs/locale/${localeCode}`) + await import(/* webpackChunkName: "dayjs-locales-[request]" */ `dayjs/locale/${localeCode}`) } } } @@ -62,6 +62,4 @@ export async function loadLanguageAsync (lang: string): Promise { return lang } -loadLanguageAsync(config.i18n.defaultLocale) - export default i18n diff --git a/core/i18n/resource/i18n/cs-CZ.csv b/core/i18n/resource/i18n/cs-CZ.csv index b4ec79f7ca..b0930afcfe 100644 --- a/core/i18n/resource/i18n/cs-CZ.csv +++ b/core/i18n/resource/i18n/cs-CZ.csv @@ -26,6 +26,7 @@ "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!" "Payment Information","Informace o platbě" @@ -36,6 +37,7 @@ "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í!" diff --git a/core/i18n/resource/i18n/de-DE.csv b/core/i18n/resource/i18n/de-DE.csv index 06d8dc0bc9..f9998fe98d 100644 --- a/core/i18n/resource/i18n/de-DE.csv +++ b/core/i18n/resource/i18n/de-DE.csv @@ -28,6 +28,7 @@ "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!" "Payment Information","Bezahlinformationen" @@ -40,6 +41,7 @@ "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!" diff --git a/core/i18n/resource/i18n/en-US.csv b/core/i18n/resource/i18n/en-US.csv index 5148be2325..ebee752413 100644 --- a/core/i18n/resource/i18n/en-US.csv +++ b/core/i18n/resource/i18n/en-US.csv @@ -29,6 +29,7 @@ "No products synchronized for this category. Please come back while online!","No products synchronized for this category. Please come back while online!" "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!" "Out of the stock!","Out of the stock!" diff --git a/core/i18n/resource/i18n/es-ES.csv b/core/i18n/resource/i18n/es-ES.csv index 2090c30774..73c6f648a1 100644 --- a/core/i18n/resource/i18n/es-ES.csv +++ b/core/i18n/resource/i18n/es-ES.csv @@ -15,10 +15,12 @@ "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!" "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." +"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!" "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 added to wishlist!","¡El producto {productName} ha sido agregado a la lista de deseos!" "Product {productName} has been removed from compare!","¡El producto {productName} ha sido eliminado de la comparación!" diff --git a/core/i18n/resource/i18n/fr-FR.csv b/core/i18n/resource/i18n/fr-FR.csv index d56c864ddc..fce1c240cf 100644 --- a/core/i18n/resource/i18n/fr-FR.csv +++ b/core/i18n/resource/i18n/fr-FR.csv @@ -20,6 +20,7 @@ "No products synchronized for this category. Please come back while online!","Aucun produit synchronisé dans cette catégorie. Merci de passer en 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." "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" @@ -27,6 +28,7 @@ "Proceed to checkout","Passer la commande" "Product has been added to the cart!","Le produit a été ajouté au panier !" "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 added to wishlist!","Le produit {productName} a été ajouté à la liste des souhaits !" "Product {productName} has been removed from compare!","Le produit {productName} a été supprimé du comparateur !" diff --git a/core/i18n/resource/i18n/it-IT.csv b/core/i18n/resource/i18n/it-IT.csv index 7e297eb330..0eaa53d823 100644 --- a/core/i18n/resource/i18n/it-IT.csv +++ b/core/i18n/resource/i18n/it-IT.csv @@ -29,6 +29,7 @@ "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" diff --git a/core/i18n/resource/i18n/jp-JP.csv b/core/i18n/resource/i18n/jp-JP.csv index 289ca86e38..122a317f8f 100644 --- a/core/i18n/resource/i18n/jp-JP.csv +++ b/core/i18n/resource/i18n/jp-JP.csv @@ -71,6 +71,7 @@ "No products yet","商品はありません" "No reviews have been posted yet. Please don\","まだレビューは投稿されていません。" "No such configuration for the product. Please do choose another combination of attributes.","この商品へのこの構成はありません。別の組み合わせを試してみてください。" +"Only {maxQuantity} products of this type are available!","Only {maxQuantity} products of this type are available!" "Open menu","メニューを開く" "Open microcart","マイクロカートを開く" "Open my account","アカウントを開く" @@ -87,6 +88,7 @@ "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","ソートしたい項目を選んでください" +"Product quantity has been updated!","製品の数量が更新されました!" "Products","商品" "Purchase","購入" "Register an account","アカウントを登録" diff --git a/core/i18n/resource/i18n/nl-NL.csv b/core/i18n/resource/i18n/nl-NL.csv index e38065ad62..2e40fee4bd 100644 --- a/core/i18n/resource/i18n/nl-NL.csv +++ b/core/i18n/resource/i18n/nl-NL.csv @@ -1,7 +1,9 @@ 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 "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" "Thumbnail","Thumbnail" diff --git a/core/i18n/resource/i18n/pl-PL.csv b/core/i18n/resource/i18n/pl-PL.csv index e84d62ddcd..27074b4bdb 100644 --- a/core/i18n/resource/i18n/pl-PL.csv +++ b/core/i18n/resource/i18n/pl-PL.csv @@ -20,6 +20,7 @@ "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!" "Please configure product custom options and fix the validation errors","Skonfiguruj opcje produktu i popraw będy walidacji" @@ -27,6 +28,7 @@ "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!" diff --git a/core/i18n/resource/i18n/pt-BR.csv b/core/i18n/resource/i18n/pt-BR.csv index 7c1ed4a36b..b7e34b4521 100644 --- a/core/i18n/resource/i18n/pt-BR.csv +++ b/core/i18n/resource/i18n/pt-BR.csv @@ -17,11 +17,13 @@ "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" +"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!" diff --git a/core/i18n/resource/i18n/pt-PT.csv b/core/i18n/resource/i18n/pt-PT.csv index fc4b5a5d97..9bd2aea39f 100644 --- a/core/i18n/resource/i18n/pt-PT.csv +++ b/core/i18n/resource/i18n/pt-PT.csv @@ -17,11 +17,13 @@ "No products synchronized for this category. Please come back while online!","Nenhum produto sincronizado para esta categoria. Por favor volte qundo estiver online!" "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." "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!" "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 added to wishlist!","Produto {productName} foi adicionado à Lista de Desejos!" "Product {productName} has been removed from compare!","Produto {productName} foi removido de Comparar Produtos!" diff --git a/core/i18n/resource/i18n/ru-RU.csv b/core/i18n/resource/i18n/ru-RU.csv index 96f5708889..68392e1d39 100644 --- a/core/i18n/resource/i18n/ru-RU.csv +++ b/core/i18n/resource/i18n/ru-RU.csv @@ -15,10 +15,12 @@ "Newsletter preferences have successfully been updated","Предпочтения по новостям успешно обновлены" "No products synchronized for this category. Please come back while online!","По данной категории товары не синхронизированы. Пожалуйста вернитесь когда будете онлайн!" "No such configuration for the product. Please do choose another combination of attributes.","У товара отсутствует данная конфигурация. Пожалуйста выберите другие значения атрибутов." +"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!","Товар добавлен в корзину!" "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} удален из страницы сравнения!" diff --git a/core/i18n/resource/i18n/zh-cn.csv b/core/i18n/resource/i18n/zh-cn.csv index 803d2c8c68..f60cf1b687 100644 --- a/core/i18n/resource/i18n/zh-cn.csv +++ b/core/i18n/resource/i18n/zh-cn.csv @@ -25,6 +25,7 @@ "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","付款信息" "Please configure product bundle options and fix the validation errors","请配置商品属性选项并修复验证错误" @@ -36,6 +37,7 @@ "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} 已从比较列表中移除!" diff --git a/core/i18n/scripts/translation.preprocessor.js b/core/i18n/scripts/translation.preprocessor.js index 6b97d8719c..7164d39ed6 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,24 +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 && typeof store === 'object' && 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/multistore.ts b/core/lib/multistore.ts index e0b1244245..249b45ee51 100644 --- a/core/lib/multistore.ts +++ b/core/lib/multistore.ts @@ -141,8 +141,11 @@ export function localizedDispatcherRoute (routeObj: LocalizedRoute | string, sto return routeObj } -export function localizedDispatcherRouteName (routeName: string, storeCode: string): string { - return storeCode ? `${storeCode}-${routeName}` : routeName +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): any { diff --git a/core/lib/search/adapter/api/elasticsearch/multimatch.js b/core/lib/search/adapter/api/elasticsearch/multimatch.js index 64a71339e9..1abd481c3a 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/graphql/gqlQuery.js b/core/lib/search/adapter/graphql/gqlQuery.js index 0bb90a660e..80595a9f7d 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 74f5b6531a..e9cdb49214 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/modules/cart/helpers/createOrderData.ts b/core/modules/cart/helpers/createOrderData.ts index d79e382425..37c12e34ac 100644 --- a/core/modules/cart/helpers/createOrderData.ts +++ b/core/modules/cart/helpers/createOrderData.ts @@ -21,6 +21,7 @@ const createOrderData = ({ shippingDetails, shippingMethods, paymentMethods, + paymentDetails, taxCountry = currentStoreView().tax.defaultCountry }: CheckoutData): OrderShippingDetails => { const country = shippingDetails.country ? shippingDetails.country : taxCountry @@ -36,6 +37,14 @@ const createOrderData = ({ 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 diff --git a/core/modules/cart/helpers/createShippingInfoData.ts b/core/modules/cart/helpers/createShippingInfoData.ts index 394cfc86ed..e42b0f19ff 100644 --- a/core/modules/cart/helpers/createShippingInfoData.ts +++ b/core/modules/cart/helpers/createShippingInfoData.ts @@ -3,6 +3,9 @@ const createShippingInfoData = (methodsData) => ({ countryId: methodsData.country, ...(methodsData.shippingAddress ? methodsData.shippingAddress : {}) }, + billingAddress: { + ...(methodsData.billingAddress ? methodsData.billingAddress : {}) + }, shippingCarrierCode: methodsData.carrier_code, shippingMethodCode: methodsData.method_code }); diff --git a/core/modules/cart/helpers/notifications.ts b/core/modules/cart/helpers/notifications.ts index d614ccdd9c..06e5872d5d 100644 --- a/core/modules/cart/helpers/notifications.ts +++ b/core/modules/cart/helpers/notifications.ts @@ -3,39 +3,39 @@ import { currentStoreView, localizedRoute } from '@vue-storefront/core/lib/multi import i18n from '@vue-storefront/i18n'; import config from 'config'; -const proceedToCheckoutAction = { +const proceedToCheckoutAction = () => ({ label: i18n.t('Proceed to checkout'), action: () => router.push(localizedRoute('/checkout', currentStoreView().storeCode)) -}; -const checkoutAction = !config.externalCheckout ? proceedToCheckoutAction : null; +}); +const checkoutAction = () => !config.externalCheckout ? proceedToCheckoutAction() : null; -const productAddedToCart = { +const productAddedToCart = () => ({ type: 'success', message: i18n.t('Product has been added to the cart!'), action1: { label: i18n.t('OK') }, - action2: checkoutAction -} + action2: checkoutAction() +}) -const productQuantityUpdated = { +const productQuantityUpdated = () => ({ type: 'success', message: i18n.t('Product quantity has been updated!'), action1: { label: i18n.t('OK') }, - action2: checkoutAction -} + action2: checkoutAction() +}) -const unsafeQuantity = { +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 = { +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 }) => diff --git a/core/modules/cart/helpers/productsEquals.ts b/core/modules/cart/helpers/productsEquals.ts index e354ee4765..92e8870fc9 100644 --- a/core/modules/cart/helpers/productsEquals.ts +++ b/core/modules/cart/helpers/productsEquals.ts @@ -15,8 +15,13 @@ const getProductType = (product: CartItem): string => const getServerItemId = (product: CartItem): string | number => product.server_item_id || product.item_id -const isServerIdsEquals = (product1: CartItem, product2: CartItem): boolean => - getServerItemId(product1) === getServerItemId(product2) +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) diff --git a/core/modules/cart/store/actions/itemActions.ts b/core/modules/cart/store/actions/itemActions.ts index 6cc595566a..3cc36d2f08 100644 --- a/core/modules/cart/store/actions/itemActions.ts +++ b/core/modules/cart/store/actions/itemActions.ts @@ -63,10 +63,10 @@ const itemActions = { const { status, onlineCheckTaskId } = await dispatch('checkProductStatus', { product }) if (status === 'volatile') { - diffLog.pushNotification(notifications.unsafeQuantity) + diffLog.pushNotification(notifications.unsafeQuantity()) } if (status === 'out_of_stock') { - diffLog.pushNotification(notifications.outOfStock) + diffLog.pushNotification(notifications.outOfStock()) } if (status === 'ok' || status === 'volatile') { @@ -75,7 +75,7 @@ const itemActions = { }) } if (productIndex === (productsToAdd.length - 1) && (!getters.isCartSyncEnabled || forceServerSilence)) { - diffLog.pushNotification(notifications.productAddedToCart) + diffLog.pushNotification(notifications.productAddedToCart()) } productIndex++ } diff --git a/core/modules/cart/store/actions/mergeActions.ts b/core/modules/cart/store/actions/mergeActions.ts index ddddc0c571..448d87f0a2 100644 --- a/core/modules/cart/store/actions/mergeActions.ts +++ b/core/modules/cart/store/actions/mergeActions.ts @@ -57,7 +57,7 @@ const mergeActions = { if (!rootGetters['checkout/isUserInCheckout']) { const isThisNewItemAddedToTheCart = (!clientItem || !clientItem.server_item_id) diffLog.pushNotification( - isThisNewItemAddedToTheCart ? notifications.productAddedToCart : notifications.productQuantityUpdated + isThisNewItemAddedToTheCart ? notifications.productAddedToCart() : notifications.productQuantityUpdated() ) } diff --git a/core/modules/cart/store/actions/methodsActions.ts b/core/modules/cart/store/actions/methodsActions.ts index 8eabf4106f..5eb415925d 100644 --- a/core/modules/cart/store/actions/methodsActions.ts +++ b/core/modules/cart/store/actions/methodsActions.ts @@ -3,7 +3,8 @@ 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 } from '@vue-storefront/core/modules/cart/helpers' +import { preparePaymentMethodsToSync, createOrderData, createShippingInfoData } from '@vue-storefront/core/modules/cart/helpers' +import PaymentMethod from '../../types/PaymentMethod' const methodsActions = { async pullMethods ({ getters, dispatch }, { forceServerSync }) { @@ -26,9 +27,30 @@ const methodsActions = { async syncPaymentMethods ({ getters, rootGetters, dispatch }, { forceServerSync = false }) { if (getters.canUpdateMethods && (getters.isTotalsSyncRequired || forceServerSync)) { Logger.debug('Refreshing payment methods', 'cart')() - const { result } = await CartService.getPaymentMethods() + 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( - result, + backendPaymentMethods, rootGetters['checkout/getNotServerPaymentMethods'] ) await dispatch('checkout/replacePaymentMethods', paymentMethods, { root: true }) diff --git a/core/modules/cart/store/actions/totalsActions.ts b/core/modules/cart/store/actions/totalsActions.ts index a31379d674..16ae4f396c 100644 --- a/core/modules/cart/store/actions/totalsActions.ts +++ b/core/modules/cart/store/actions/totalsActions.ts @@ -15,7 +15,7 @@ const totalsActions = { return CartService.getTotals() }, - async overrideServerTotals ({ commit, dispatch }, { addressInformation, hasShippingInformation }) { + async overrideServerTotals ({ commit, getters, dispatch }, { addressInformation, hasShippingInformation }) { const { resultCode, result } = await dispatch('getTotals', { addressInformation, hasShippingInformation }) if (resultCode === 200) { @@ -32,6 +32,11 @@ const totalsActions = { 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) { + dispatch('checkout/replacePaymentMethods', result.payment_methods, { root: true }) + } + return } @@ -45,7 +50,8 @@ const totalsActions = { const shippingMethodsData = methodsData || createOrderData({ shippingDetails: rootGetters['checkout/getShippingDetails'], shippingMethods: rootGetters['checkout/getShippingMethods'], - paymentMethods: rootGetters['checkout/getPaymentMethods'] + paymentMethods: rootGetters['checkout/getPaymentMethods'], + paymentDetails: rootGetters['checkout/getPaymentDetails'] }) if (shippingMethodsData.country) { diff --git a/core/modules/cart/test/unit/helpers/createOrderData.spec.ts b/core/modules/cart/test/unit/helpers/createOrderData.spec.ts index c81c876c74..411162be95 100644 --- a/core/modules/cart/test/unit/helpers/createOrderData.spec.ts +++ b/core/modules/cart/test/unit/helpers/createOrderData.spec.ts @@ -19,6 +19,23 @@ const shippingDetails = { 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 = [ @@ -47,7 +64,7 @@ describe('Cart createOrderData', () => { } ]; - const methodsData = createOrderData({ shippingDetails, shippingMethods, paymentMethods, taxCountry: 'DE' }) + const methodsData = createOrderData({ shippingDetails, shippingMethods, paymentMethods, paymentDetails, taxCountry: 'DE' }) expect(methodsData).toEqual({ carrier_code: 'CODE4', @@ -60,6 +77,14 @@ describe('Cart createOrderData', () => { lastname: 'Doe', postcode: 'EC123', street: ['JohnDoe street'] + }, + billingAddress: { + city: 'London', + countryId: 'UK', + firstname: 'John', + lastname: 'Doe', + postcode: 'EC123', + street: ['JohnDoe street'] } }); }); @@ -91,7 +116,7 @@ describe('Cart createOrderData', () => { } ]; - const methodsData = createOrderData({ shippingDetails, shippingMethods, paymentMethods, taxCountry: 'DE' }) + const methodsData = createOrderData({ shippingDetails, shippingMethods, paymentMethods, paymentDetails, taxCountry: 'DE' }) expect(methodsData).toEqual({ carrier_code: 'CODE2-first', @@ -104,6 +129,14 @@ describe('Cart createOrderData', () => { lastname: 'Doe', postcode: 'EC123', street: ['JohnDoe street'] + }, + billingAddress: { + city: 'London', + countryId: 'UK', + firstname: 'John', + lastname: 'Doe', + postcode: 'EC123', + street: ['JohnDoe street'] } }); }); @@ -111,7 +144,7 @@ describe('Cart createOrderData', () => { it('returns data without payment, carrier and method', async () => { const shippingMethods = []; const paymentMethods = []; - const methodsData = createOrderData({ shippingDetails, shippingMethods, paymentMethods, taxCountry: 'DE' }); + const methodsData = createOrderData({ shippingDetails, shippingMethods, paymentMethods, paymentDetails, taxCountry: 'DE' }); expect(methodsData).toEqual({ carrier_code: null, @@ -124,6 +157,14 @@ describe('Cart createOrderData', () => { 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 index 277ae13103..ed2bfbfc80 100644 --- a/core/modules/cart/test/unit/helpers/createShippingInfoData.spec.ts +++ b/core/modules/cart/test/unit/helpers/createShippingInfoData.spec.ts @@ -9,6 +9,7 @@ describe('Cart createShippingInfoData', () => { }; const shippingInfoData = createShippingInfoData(methodsData); expect(shippingInfoData).toEqual({ + billingAddress: {}, shippingAddress: { countryId: 'UK' }, @@ -32,6 +33,7 @@ describe('Cart createShippingInfoData', () => { }; const shippingInfoData = createShippingInfoData(methodsData); expect(shippingInfoData).toEqual({ + billingAddress: {}, shippingAddress: { city: 'London', countryId: 'UK', @@ -44,4 +46,34 @@ describe('Cart createShippingInfoData', () => { 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/store/methodsActions.spec.ts b/core/modules/cart/test/unit/store/methodsActions.spec.ts index 3980de6237..907410a941 100644 --- a/core/modules/cart/test/unit/store/methodsActions.spec.ts +++ b/core/modules/cart/test/unit/store/methodsActions.spec.ts @@ -1,6 +1,6 @@ import * as types from '@vue-storefront/core/modules/cart/store/mutation-types'; import { CartService } from '@vue-storefront/core/data-resolver'; -import { preparePaymentMethodsToSync } from '@vue-storefront/core/modules/cart/helpers'; +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'; @@ -59,7 +59,8 @@ jest.mock('@vue-storefront/core/modules/cart/helpers', () => ({ merge: jest.fn(), isEmpty: jest.fn() })), - preparePaymentMethodsToSync: jest.fn() + preparePaymentMethodsToSync: jest.fn(), + createOrderData: jest.fn() })); jest.mock('@vue-storefront/core/helpers', () => ({ get isServer () { @@ -119,7 +120,8 @@ describe('Cart methodsActions', () => { it('synchronizes payment methods', async () => { const contextMock = createContextMock({ rootGetters: { - 'checkout/getNotServerPaymentMethods': [] + 'checkout/getNotServerPaymentMethods': [], + 'checkout/getPaymentDetails': { country: 'US' } }, getters: { canUpdateMethods: true, @@ -128,6 +130,7 @@ describe('Cart methodsActions', () => { }); (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, {}); diff --git a/core/modules/cart/types/BillingAddress.ts b/core/modules/cart/types/BillingAddress.ts new file mode 100644 index 0000000000..be0bae3f4b --- /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/CheckoutData.ts b/core/modules/cart/types/CheckoutData.ts index feac99cb00..4e2c5c676e 100644 --- a/core/modules/cart/types/CheckoutData.ts +++ b/core/modules/cart/types/CheckoutData.ts @@ -1,10 +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/OrderShippingDetails.ts b/core/modules/cart/types/OrderShippingDetails.ts index a455dda4d2..ebfc2005c5 100644 --- a/core/modules/cart/types/OrderShippingDetails.ts +++ b/core/modules/cart/types/OrderShippingDetails.ts @@ -1,9 +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 + shippingAddress?: ShippingAddress, + billingAddress?: BillingAddress } diff --git a/core/modules/catalog-next/helpers/filterHelpers.ts b/core/modules/catalog-next/helpers/filterHelpers.ts index cac1d3702a..9981aba2bb 100644 --- a/core/modules/catalog-next/helpers/filterHelpers.ts +++ b/core/modules/catalog-next/helpers/filterHelpers.ts @@ -1,6 +1,7 @@ -import FilterVariant from 'core/modules/catalog-next/types/FilterVariant'; +import config from 'config' +import FilterVariant from 'core/modules/catalog-next/types/FilterVariant' -export const getSystemFilterNames: string[] = ['sort'] +export const getSystemFilterNames: string[] = config.products.systemFilterNames /** * Creates new filtersQuery (based on currentQuery) by modifying specific filter variant. diff --git a/core/modules/catalog-next/store/category/actions.ts b/core/modules/catalog-next/store/category/actions.ts index b9de48da1d..82703450dc 100644 --- a/core/modules/catalog-next/store/category/actions.ts +++ b/core/modules/catalog-next/store/category/actions.ts @@ -23,7 +23,7 @@ import omit from 'lodash-es/omit' import config from 'config' const actions: ActionTree = { - async loadCategoryProducts ({ commit, getters, dispatch, rootState }, { route, category } = {}) { + 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 @@ -36,7 +36,8 @@ const actions: ActionTree = { query: filterQr, sort: searchQuery.sort, includeFields: entities.productList.includeFields, - excludeFields: entities.productList.excludeFields + 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 }) @@ -47,7 +48,8 @@ const actions: ActionTree = { }, async loadMoreCategoryProducts ({ commit, getters, rootState, dispatch }) { const { perPage, start, total } = getters.getCategorySearchProductsStats - if (start >= total || total < perPage) return + 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) @@ -109,14 +111,14 @@ const actions: ActionTree = { }) }, async registerCategoryProductsMapping ({ dispatch }, products = []) { - const storeCode = currentStoreView().storeCode + 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) + 'name': localizedDispatcherRouteName(type_id + '-product', storeCode, appendStoreCode) } }, { root: true }) })) diff --git a/core/modules/catalog-next/store/category/getters.ts b/core/modules/catalog-next/store/category/getters.ts index 6f20defe19..9cff3f2ca5 100644 --- a/core/modules/catalog-next/store/category/getters.ts +++ b/core/modules/catalog-next/store/category/getters.ts @@ -107,7 +107,7 @@ const getters: GetterTree = { 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: () => ['sort'], + getSystemFilterNames: () => products.systemFilterNames, getBreadcrumbs: (state, getters) => getters.getBreadcrumbsFor(getters.getCurrentCategory), getBreadcrumbsFor: (state, getters) => category => { if (!category) return [] @@ -118,7 +118,12 @@ const getters: GetterTree = { return parseCategoryPath(resultCategoryList) }, getCategorySearchProductsStats: state => state.searchProductsStats || {}, - getCategoryProductsTotal: (state, getters) => getters.getCategorySearchProductsStats.total || 0 + 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/components/ProductBundleOption.ts b/core/modules/catalog/components/ProductBundleOption.ts index bd770652bc..d902cbead3 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/ProductCustomOptions.ts b/core/modules/catalog/components/ProductCustomOptions.ts index 34f80a5da6..821eecde03 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,21 +15,34 @@ 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() @@ -50,26 +52,24 @@ export const ProductCustomOptions = { 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/helpers/customOption.ts b/core/modules/catalog/helpers/customOption.ts new file mode 100644 index 0000000000..dbd31387fc --- /dev/null +++ b/core/modules/catalog/helpers/customOption.ts @@ -0,0 +1,44 @@ +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) + + return String(selectedValue && selectedValue.option_type_id) || '' + } + case 'checkbox': { + const checkboxOptionValues = inputValue as number[] || [] + + return optionValues.filter((value) => checkboxOptionValues.includes(value.option_type_id)) + .map((value) => value.option_type_id) + .join(',') + } + default: { + return '' + } + } +} diff --git a/core/modules/catalog/helpers/taxCalc.ts b/core/modules/catalog/helpers/taxCalc.ts index 55f90498ac..927a2f2d23 100644 --- a/core/modules/catalog/helpers/taxCalc.ts +++ b/core/modules/catalog/helpers/taxCalc.ts @@ -1,3 +1,5 @@ +import camelCase from 'lodash-es/camelCase' + // this is the mirror copy of taxcalc.js from VSF API function isSpecialPriceActive (fromDate, toDate) { @@ -22,88 +24,103 @@ function isSpecialPriceActive (fromDate, toDate) { } } +/** + * 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 - if (finalPriceInclTax) { - product.final_price_incl_tax = parseFloat(product.final_price) // final price does include tax - product.final_price = product.final_price_incl_tax / (1 + rate_factor) - product.final_price_tax = product.final_price_incl_tax - product.final_price - } else { - product.final_price = parseFloat(product.final_price) // final price does include tax - product.final_price_tax = product.final_price * rate_factor - product.final_price_incl_tax = product.final_price + product.final_price_tax + 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}) + + product.original_final_price = finalPriceWithTax.price + product.original_special_price = specialPriceWithTax.price } - product.price = parseFloat(product.price) - product.special_price = parseFloat(product.special_price) + + // reset previous calculation + assignPrice({product, target: 'price', ...priceWithTax, deprecatedPriceFieldsSupport}) + assignPrice({product, target: 'final_price', ...finalPriceWithTax, deprecatedPriceFieldsSupport}) + assignPrice({product, target: 'special_price', ...specialPriceWithTax, 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 if (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` - product.price = product.special_price // if the `final_price` is lower than the original `special_price` - it means some catalog rules were applied over it + 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 } - product.special_price = product.final_price + assignPrice({product, target: 'special_price', ...finalPriceWithTax, deprecatedPriceFieldsSupport}) } else { - product.price = product.final_price + assignPrice({product, target: 'price', ...finalPriceWithTax, deprecatedPriceFieldsSupport}) } } - let price_excl_tax = product.price - if (sourcePriceInclTax) { - price_excl_tax = product.price / (1 + rate_factor) - product.price = price_excl_tax - } - - product.price_tax = price_excl_tax * rate_factor - product.price_incl_tax = price_excl_tax + product.price_tax - - if (!product.original_price) { - product.original_price = price_excl_tax - product.original_price_incl_tax = product.price_incl_tax - product.original_price_tax = product.price_tax - } - - let special_price_excl_tax = product.special_price - if (sourcePriceInclTax) { - special_price_excl_tax = product.special_price / (1 + rate_factor) - product.special_price = special_price_excl_tax - } - - product.special_price_tax = special_price_excl_tax * rate_factor - product.special_price_incl_tax = special_price_excl_tax + product.special_price_tax - - if (deprecatedPriceFieldsSupport) { - /** BEGIN @deprecated - inconsitent naming kept just for the backward compatibility */ - product.priceTax = product.price_tax - product.priceInclTax = product.price_incl_tax - product.specialPriceTax = product.special_price_tax - product.specialPriceInclTax = product.special_price_incl_tax - /** END */ - } - 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 + // out of the dates period + assignPrice({product, target: 'special_price', price: 0, tax: 0, deprecatedPriceFieldsSupport}) } else { - product.original_price = price_excl_tax - product.original_price_incl_tax = product.price_incl_tax - product.original_price_tax = product.price_tax - - product.price = special_price_excl_tax - product.price_incl_tax = product.special_price_incl_tax - product.price_tax = product.special_price_tax - - if (deprecatedPriceFieldsSupport) { - /** 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 - /** END */ - } + assignPrice({product, target: 'price', ...specialPriceWithTax, deprecatedPriceFieldsSupport}) } } else { - product.special_price = 0 // the same price as original; it's not a promotion + // the same price as original; it's not a promotion + assignPrice({product, target: 'special_price', price: 0, tax: 0, deprecatedPriceFieldsSupport}) } if (product.configurable_children) { @@ -113,100 +130,25 @@ export function updateProductPrices ({ product, rate, sourcePriceInclTax = false configurableChild[opt.attribute_code] = opt.value } } - configurableChild.price = parseFloat(configurableChild.price) - configurableChild.special_price = parseFloat(configurableChild.special_price) - configurableChild.final_price_incl_tax = parseFloat(configurableChild.final_price) // final price does include tax - configurableChild.final_price = configurableChild.final_price_incl_tax / (1 + rate_factor) - - if (configurableChild.final_price) { - if (configurableChild.final_price < configurableChild.price) { // compare the prices with the product final price if provided; final prices is used in case of active catalog promo rules for example - if (configurableChild.final_price < configurableChild.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` - configurableChild.price = configurableChild.special_price // if the `final_price` is lower than the original `special_price` - it means some catalog rules were applied over it - } - configurableChild.special_to_date = null - configurableChild.special_from_date = null - configurableChild.special_price = product.final_price - } else { - configurableChild.price = configurableChild.final_price - } - } - - let price_excl_tax = configurableChild.price - if (sourcePriceInclTax) { - price_excl_tax = configurableChild.price / (1 + rate_factor) - configurableChild.price = price_excl_tax - } - configurableChild.price_tax = price_excl_tax * rate_factor - configurableChild.price_incl_tax = price_excl_tax + configurableChild.price_tax - - let special_price_excl_tax = parseFloat(configurableChild.special_price) - - if (sourcePriceInclTax) { - special_price_excl_tax = configurableChild.special_price / (1 + rate_factor) - configurableChild.special_price = special_price_excl_tax - } - - configurableChild.special_price_tax = special_price_excl_tax * rate_factor - configurableChild.special_price_incl_tax = special_price_excl_tax + configurableChild.special_price_tax - - if (deprecatedPriceFieldsSupport) { - /** BEGIN @deprecated - inconsitent naming kept just for the backward compatibility */ - configurableChild.priceTax = configurableChild.price_tax - configurableChild.priceInclTax = configurableChild.price_incl_tax - configurableChild.specialPriceTax = configurableChild.special_price_tax - configurableChild.specialPriceInclTax = configurableChild.special_price_incl_tax - /** END */ - } - - 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.original_price = price_excl_tax - configurableChild.original_price_incl_tax = configurableChild.price_incl_tax - configurableChild.original_price_tax = configurableChild.price_tax - - configurableChild.price = special_price_excl_tax - configurableChild.price_incl_tax = configurableChild.special_price_incl_tax - configurableChild.price_tax = configurableChild.special_price_tax - - if (deprecatedPriceFieldsSupport) { - /** BEGIN @deprecated - inconsitent naming kept just for the backward compatibility */ - configurableChild.originalPrice = configurableChild.original_price - configurableChild.originalPriceInclTax = configurableChild.original_price_incl_tax - configurableChild.originalPriceTax = configurableChild.original_price_tax - configurableChild.priceInclTax = configurableChild.price_incl_tax - configurableChild.priceTax = configurableChild.price_tax - /** END */ - } - } - } else { - configurableChild.special_price = 0 - } + // 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 - product.price_incl_tax = configurableChild.price_incl_tax - product.price_tax = configurableChild.price_tax - product.price = configurableChild.price - product.special_price = configurableChild.special_price - product.special_price_incl_tax = configurableChild.special_price_incl_tax - product.special_price_tax = configurableChild.special_price_tax - product.original_price = configurableChild.original_price - product.original_price_incl_tax = configurableChild.original_price_incl_tax - product.original_price_tax = configurableChild.original_price_tax - - if (deprecatedPriceFieldsSupport) { - /** BEGIN @deprecated - inconsitent naming kept just for the backward compatibility */ - product.priceInclTax = product.price_incl_tax - product.priceTax = product.price_tax - product.specialPriceInclTax = product.special_price_incl_tax - product.specialPriceTax = product.special_price_tax - product.originalPrice = product.original_price - product.originalPriceInclTax = product.original_price_incl_tax - product.originalPriceTax = product.original_price_tax - /** END */ - } + 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 + }) } } } @@ -270,4 +212,5 @@ export function calculateProductTax ({ product, taxClasses, taxCountry = 'PL', t } } } + return product } diff --git a/core/modules/catalog/store/attribute/getters.ts b/core/modules/catalog/store/attribute/getters.ts index 938eabea0f..bda43dcc05 100644 --- a/core/modules/catalog/store/attribute/getters.ts +++ b/core/modules/catalog/store/attribute/getters.ts @@ -13,7 +13,7 @@ const getters: GetterTree = { getBlacklist: (state) => state.blacklist, getAllComparableAttributes: (state, getters) => { const attributesByCode = getters.getAttributeListByCode - return Object.values(attributesByCode).filter((a: any) => parseInt(a.is_comparable)) + 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 } } diff --git a/core/modules/catalog/store/category/actions.ts b/core/modules/catalog/store/category/actions.ts index a98f491cd6..d1f0cd8f70 100644 --- a/core/modules/catalog/store/category/actions.ts +++ b/core/modules/catalog/store/category/actions.ts @@ -63,7 +63,7 @@ const actions: ActionTree = { return list }, async registerCategoryMapping ({ dispatch }, { categories }) { - const storeCode = currentStoreView().storeCode + const { storeCode, appendStoreCode } = currentStoreView() for (let category of categories) { if (category.url_path) { await dispatch('url/registerMapping', { @@ -72,7 +72,7 @@ const actions: ActionTree = { params: { 'slug': category.slug }, - 'name': localizedDispatcherRouteName('category', storeCode) + 'name': localizedDispatcherRouteName('category', storeCode, appendStoreCode) } }, { root: true }) } @@ -130,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) 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' diff --git a/core/modules/catalog/store/product/actions.ts b/core/modules/catalog/store/product/actions.ts index 46e9b99590..49fd45011d 100644 --- a/core/modules/catalog/store/product/actions.ts +++ b/core/modules/catalog/store/product/actions.ts @@ -296,7 +296,7 @@ const actions: ActionTree = { return searchResult }, preConfigureAssociated (context, { searchResult, prefetchGroupProducts }) { - const storeCode = currentStoreView().storeCode + const { storeCode, appendStoreCode } = currentStoreView() for (let product of searchResult.items) { if (product.url_path) { const { parentSku, slug } = product @@ -305,7 +305,7 @@ const actions: ActionTree = { url: localizedDispatcherRoute(product.url_path, storeCode), routeData: { params: { parentSku, slug }, - 'name': localizedDispatcherRouteName(product.type_id + '-product', storeCode) + 'name': localizedDispatcherRouteName(product.type_id + '-product', storeCode, appendStoreCode) } }, { root: true }) } diff --git a/core/modules/catalog/types/CustomOption.ts b/core/modules/catalog/types/CustomOption.ts new file mode 100644 index 0000000000..d9c6c9a399 --- /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/checkout/components/Payment.ts b/core/modules/checkout/components/Payment.ts index d950ef3f83..a9895efdaa 100644 --- a/core/modules/checkout/components/Payment.ts +++ b/core/modules/checkout/components/Payment.ts @@ -233,6 +233,9 @@ 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('cart/syncPaymentMethods', { forceServerSync: true }) } } } diff --git a/core/modules/cms/store/block/getters.ts b/core/modules/cms/store/block/getters.ts index a6dea8c0d3..c70f5333f7 100644 --- a/core/modules/cms/store/block/getters.ts +++ b/core/modules/cms/store/block/getters.ts @@ -6,7 +6,7 @@ const getters: GetterTree = { // @deprecated cmsBlocks: (state, getters) => getters.getCmsBlocks, // @deprecated - cmsBlockIdentifier: (state, getters) => (identifier) => getters.cmsBlockIdentifier(identifier), + cmsBlockIdentifier: (state, getters) => (identifier) => getters.getCmsBlockByIdentifier(identifier), // @deprecated cmsBlockId: (state, getters) => (id) => getters.getCmsBlockById(id), getCmsBlockByIdentifier: (state) => (identifier) => 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 0000000000..a36001b3e8 --- /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/test/unit/store/index.spec.ts b/core/modules/newsletter/test/unit/store/index.spec.ts new file mode 100644 index 0000000000..5eaec3960a --- /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/order/helpers/notifications.ts b/core/modules/order/helpers/notifications.ts index 4a33bd847a..2c419bcce1 100644 --- a/core/modules/order/helpers/notifications.ts +++ b/core/modules/order/helpers/notifications.ts @@ -1,17 +1,17 @@ import i18n from '@vue-storefront/i18n' import config from 'config' -const internalValidationError = { +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 = { +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 } diff --git a/core/modules/order/store/actions.ts b/core/modules/order/store/actions.ts index 6b3d17097c..145483c48c 100644 --- a/core/modules/order/store/actions.ts +++ b/core/modules/order/store/actions.ts @@ -69,7 +69,7 @@ const actions: ActionTree = { 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('notification/spawnNotification', notifications.internalValidationError(), { root: true }) dispatch('enqueueOrder', { newOrder: order }) return task @@ -80,7 +80,7 @@ const actions: ActionTree = { 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('notification/spawnNotification', notifications.orderCannotTransfered(), { root: true }) dispatch('enqueueOrder', { newOrder: order }) EventBus.$emit('notification-progress-stop') diff --git a/core/modules/url/store/actions.ts b/core/modules/url/store/actions.ts index 66568ccd9f..cb59ae9ca5 100644 --- a/core/modules/url/store/actions.ts +++ b/core/modules/url/store/actions.ts @@ -66,7 +66,7 @@ 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 = currentStoreView().storeCode + const { storeCode, appendStoreCode } = currentStoreView() const productQuery = new SearchQuery() url = (removeStoreCodeFromRoute(url.startsWith('/') ? url.slice(1) : url) as string) productQuery.applyFilter({key: 'url_path', value: {'eq': url}}) // Tees category @@ -74,7 +74,7 @@ export const actions: ActionTree = { if (products && products.items && products.items.length) { const product = products.items[0] return { - name: localizedDispatcherRouteName(product.type_id + '-product', storeCode), + name: localizedDispatcherRouteName(product.type_id + '-product', storeCode, appendStoreCode), params: { slug: product.slug, parentSku: product.sku, @@ -85,7 +85,7 @@ export const actions: ActionTree = { const category = await dispatch('category/single', { key: 'url_path', value: url }, { root: true }) if (category !== null) { return { - name: localizedDispatcherRouteName('category', storeCode), + name: localizedDispatcherRouteName('category', storeCode, appendStoreCode), params: { slug: category.slug } 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 0000000000..00963618bc --- /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 0000000000..dcd3b54f68 --- /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 0000000000..dcd194f1b4 --- /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 0000000000..df38ebf7eb --- /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 0000000000..e23505b283 --- /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 0000000000..8ceb929a4c --- /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 0000000000..b966e4159d --- /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 0000000000..f6eb698a3c --- /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 0000000000..9bf2512ea8 --- /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 0000000000..bba28e6b4f --- /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 0000000000..bc69e2365c --- /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/pages/Checkout.js b/core/pages/Checkout.js index b4fb31ed88..19040a24b6 100644 --- a/core/pages/Checkout.js +++ b/core/pages/Checkout.js @@ -137,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() }, diff --git a/core/scripts/server.ts b/core/scripts/server.ts index fde457d835..c6652e60b8 100755 --- a/core/scripts/server.ts +++ b/core/scripts/server.ts @@ -1,17 +1,24 @@ -require('../../src/trace').default() +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 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}') + +serverExtensions.forEach(serverModule => { + require(resolve(serverModule)) +}) + +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') const ssr = require('./utils/ssr-renderer') -const serverExtensions = require(resolve('src/modules/server')) -let config = require('config') const compileOptions = { escape: /{{([^{][\s\S]+?[^}])}}/g, @@ -24,13 +31,7 @@ process['noDeprecation'] = true const app = express() -serverExtensions.serverModules.forEach(serverModule => { - if (Array.isArray(serverModule)) { - require(resolve(serverModule[0] + '/server.ts'))(app, serverModule[1]) - } else { - require(resolve(serverModule + '/server.ts'))(app) - } -}) +serverHooksExecutors.afterApplicationInitialized({ app, config: config.server, isProd }) const templatesCache = ssr.initTemplatesCache(config, compileOptions) @@ -71,6 +72,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 @@ -82,6 +86,9 @@ 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 => { @@ -178,6 +185,21 @@ app.get('*', (req, res, next) => { res.setHeader('X-VS-Cache-Tags', cacheTags) console.log(`cache tags for the request: ${cacheTags}`) } + + 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( @@ -186,7 +208,23 @@ 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) diff --git a/core/server/hooks.ts b/core/server/hooks.ts new file mode 100644 index 0000000000..f7941c7955 --- /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/docker/vue-storefront/Dockerfile b/docker/vue-storefront/Dockerfile index f0aa2896bd..5fb728d9b0 100644 --- a/docker/vue-storefront/Dockerfile +++ b/docker/vue-storefront/Dockerfile @@ -7,7 +7,7 @@ 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 git python make g++ \ && yarn install --no-cache \ && apk del .build-deps diff --git a/docs/guide/basics/configuration.md b/docs/guide/basics/configuration.md index 21b2c3b32f..4382698a95 100644 --- a/docs/guide/basics/configuration.md +++ b/docs/guide/basics/configuration.md @@ -496,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 diff --git a/docs/guide/cookbook/setup.md b/docs/guide/cookbook/setup.md index 84075a4e6e..ea977f50b7 100644 --- a/docs/guide/cookbook/setup.md +++ b/docs/guide/cookbook/setup.md @@ -1131,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" }, diff --git a/package.json b/package.json index 7421865ff4..a007fa378f 100755 --- a/package.json +++ b/package.json @@ -50,7 +50,10 @@ }, "lint-staged": { "*.{js,vue,ts}": "eslint", - "**/i18n/*.csv": ["node ./core/scripts/utils/sort-translations.js", "git add"] + "**/i18n/*.csv": [ + "node ./core/scripts/utils/sort-translations.js", + "git add" + ] }, "husky": { "hooks": { @@ -72,6 +75,7 @@ "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", @@ -145,6 +149,7 @@ "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", diff --git a/src/modules/compress/server.ts b/src/modules/compress/server.ts index add229106f..3ca3054bc7 100644 --- a/src/modules/compress/server.ts +++ b/src/modules/compress/server.ts @@ -1,8 +1,9 @@ +import { serverHooks } from '@vue-storefront/core/server/hooks' const compression = require('compression') -module.exports = (app, moduleConfig) => { - if (moduleConfig.enabled) { +serverHooks.afterApplicationInitialized(({ app, isProd }) => { + if (isProd) { console.log('Output Compression is enabled') - app.use(compression(moduleConfig)) + app.use(compression({ enabled: isProd })) } -} +}) diff --git a/src/trace/googleCloud/package.json b/src/modules/google-cloud-trace/package.json similarity index 100% rename from src/trace/googleCloud/package.json rename to src/modules/google-cloud-trace/package.json diff --git a/src/modules/google-cloud-trace/server.ts b/src/modules/google-cloud-trace/server.ts new file mode 100644 index 0000000000..5e7db50b0b --- /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/instant-checkout/components/InstantCheckout.vue b/src/modules/instant-checkout/components/InstantCheckout.vue index dea5e0bc72..6987182579 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, diff --git a/src/modules/robots/server.ts b/src/modules/robots/server.ts index c79de50968..f245ca1240 100644 --- a/src/modules/robots/server.ts +++ b/src/modules/robots/server.ts @@ -1,6 +1,7 @@ +import { serverHooks } from '@vue-storefront/core/server/hooks' -module.exports = (app) => { +serverHooks.afterApplicationInitialized(({ app }) => { app.get('/robots.txt', (req, res) => { res.end('User-agent: *\nDisallow: ') }) -} +}) diff --git a/src/modules/server.ts b/src/modules/server.ts deleted file mode 100644 index b2f626e4c3..0000000000 --- a/src/modules/server.ts +++ /dev/null @@ -1,6 +0,0 @@ -const isProd = process.env.NODE_ENV === 'production' - -export const serverModules = [ - 'src/modules/robots' - // ['src/modules/compress', { enabled: isProd }] -] diff --git a/src/themes/default/components/core/ProductBundleOptions.vue b/src/themes/default/components/core/ProductBundleOptions.vue index 3d12c8a8ea..d36f910e3f 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 5bf74143f7..7a59caeb31 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/ProductTile.vue b/src/themes/default/components/core/ProductTile.vue index af345733a5..7f931c0a39 100644 --- a/src/themes/default/components/core/ProductTile.vue +++ b/src/themes/default/components/core/ProductTile.vue @@ -208,16 +208,20 @@ $color-white: color(white); will-change: opacity, transform; transition: 0.3s opacity $motion-main, 0.3s transform $motion-main; } - &:hover { - .product-cover__thumb { - opacity: 1; - transform: scale(1.1); - } - &.sale::after, - &.new::after { - opacity: 0.8; + + @media screen and (min-width: 768px) { + &:hover { + .product-cover__thumb { + opacity: 1; + transform: scale(1.1); + } + &.sale::after, + &.new::after { + opacity: 0.8; + } } } + &.sale { &::after { @extend %label; diff --git a/src/themes/default/components/core/blocks/Checkout/Payment.vue b/src/themes/default/components/core/blocks/Checkout/Payment.vue index 6084ad3e4c..9fce7c6e37 100644 --- a/src/themes/default/components/core/blocks/Checkout/Payment.vue +++ b/src/themes/default/components/core/blocks/Checkout/Payment.vue @@ -121,10 +121,16 @@ v-model.trim="payment.city" @blur="$v.payment.city.$touch()" autocomplete="address-level2" - :validations="[{ + :validations="[ + { condition: $v.payment.city.$error && !$v.payment.city.required, text: $t('Field is required') - }]" + }, + { + condition: $v.payment.city.$error && $v.payment.city.required, + text: $t('Please provide valid city name') + } + ]" />
- + {{ $t('Store locator') }}
diff --git a/src/themes/default/components/core/blocks/Microcart/EditMode.vue b/src/themes/default/components/core/blocks/Microcart/EditMode.vue index 70da1ab9c9..41ee04bb96 100644 --- a/src/themes/default/components/core/blocks/Microcart/EditMode.vue +++ b/src/themes/default/components/core/blocks/Microcart/EditMode.vue @@ -1,5 +1,6 @@