diff --git a/changelog/_unreleased/2020-11-24-create-cross-selling-element.md b/changelog/_unreleased/2020-11-24-create-cross-selling-element.md new file mode 100644 index 00000000000..1140e3697ae --- /dev/null +++ b/changelog/_unreleased/2020-11-24-create-cross-selling-element.md @@ -0,0 +1,9 @@ +--- +title: create "Cross Selling" element +issue: NEXT-12059 +flag: FEATURE_NEXT_10078 +--- +# Administration +* Added `sw-cms-el-cross-selling` component +* Added `sw-cms-el-config-cross-selling` component +* Added `sw-cms-el-preview-cross-selling` component diff --git a/src/Administration/Resources/app/administration/src/module/sw-cms/acl/index.js b/src/Administration/Resources/app/administration/src/module/sw-cms/acl/index.js index 13beba703d9..14656278e01 100644 --- a/src/Administration/Resources/app/administration/src/module/sw-cms/acl/index.js +++ b/src/Administration/Resources/app/administration/src/module/sw-cms/acl/index.js @@ -20,7 +20,9 @@ Shopware.Service('privileges') 'property_group:read', 'property_group_option:read', 'product_media:read', - 'delivery_time:read' + 'delivery_time:read', + 'product_cross_selling:read', + 'product_cross_selling_assigned_products:read' ], dependencies: [] }, diff --git a/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/component/index.js b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/component/index.js new file mode 100644 index 00000000000..4b4a33e0966 --- /dev/null +++ b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/component/index.js @@ -0,0 +1,179 @@ +import template from './sw-cms-el-cross-selling.html.twig'; +import './sw-cms-el-cross-selling.scss'; + +const { Component, Mixin } = Shopware; +const { isEmpty } = Shopware.Utils.types; + +Component.register('sw-cms-el-cross-selling', { + template, + + mixins: [ + Mixin.getByName('cms-element'), + Mixin.getByName('placeholder') + ], + + data() { + return { + sliderBoxLimit: 3 + }; + }, + + computed: { + demoProductElement() { + return { + config: { + boxLayout: { + source: 'static', + value: this.element.config.boxLayout.value + }, + displayMode: { + source: 'static', + value: this.element.config.displayMode.value + }, + elMinWidth: { + source: 'static', + value: this.element.config.elMinWidth.value + } + }, + data: { + product: { + name: 'Lorem ipsum dolor', + description: `Lorem ipsum dolor sit amet, consetetur sadipscing elitr, + sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, + sed diam voluptua.`.trim(), + price: [ + { gross: 19.90 } + ], + cover: { + media: { + url: '/administration/static/img/cms/preview_glasses_large.jpg', + alt: 'Lorem Ipsum dolor' + } + } + } + } + }; + }, + + sliderBoxMinWidth() { + if (this.element.config.elMinWidth.value && this.element.config.elMinWidth.value.indexOf('px') > -1) { + return `repeat(auto-fit, minmax(${this.element.config.elMinWidth.value}, 1fr))`; + } + + return null; + }, + + currentDeviceView() { + return this.cmsPageState.currentCmsDeviceView; + }, + + crossSelling() { + if (!this.element.data.product || !this.element.data.product.crossSellings.length) { + return { + name: 'Similar products' + }; + } + + return this.element.data.product.crossSellings[0]; + }, + + crossSellingProducts() { + return (this.element.data.product.crossSellings.length) + ? this.element.data.product.crossSellings[0].assignedProducts + : []; + }, + + currentDemoEntity() { + if (this.cmsPageState.currentMappingEntity === 'product') { + return this.cmsPageState.currentDemoEntity; + } + + return null; + } + }, + + watch: { + 'element.config.elMinWidth.value': { + handler() { + this.setSliderRowLimit(); + } + }, + + currentDeviceView() { + setTimeout(() => { + this.setSliderRowLimit(); + }, 400); + } + }, + + created() { + this.createdComponent(); + }, + + mounted() { + this.mountedComponent(); + }, + + methods: { + createdComponent() { + this.initElementConfig('cross-selling'); + this.initElementData('cross-selling'); + }, + + mountedComponent() { + this.setSliderRowLimit(); + }, + + setSliderRowLimit() { + if (isEmpty(this.element.config)) { + this.createdComponent(); + } + + if (this.currentDeviceView === 'mobile' || (this.$refs.productHolder && this.$refs.productHolder.offsetWidth < 500)) { + this.sliderBoxLimit = 1; + return; + } + + if (!this.element.config.elMinWidth.value || + this.element.config.elMinWidth.value === 'px' || + this.element.config.elMinWidth.value.indexOf('px') === -1) { + this.sliderBoxLimit = 3; + return; + } + + if (parseInt(this.element.config.elMinWidth.value.replace('px', ''), 10) <= 0) { + return; + } + + // Subtract to fake look in storefront which has more width + const fakeLookWidth = 100; + const boxWidth = this.$refs.productHolder.offsetWidth; + const elGap = 32; + let elWidth = parseInt(this.element.config.elMinWidth.value.replace('px', ''), 10); + + if (elWidth >= 300) { + elWidth -= fakeLookWidth; + } + + this.sliderBoxLimit = Math.floor(boxWidth / (elWidth + elGap)); + }, + + getProductEl(product) { + return { + config: { + boxLayout: { + source: 'static', + value: this.element.config.boxLayout.value + }, + displayMode: { + source: 'static', + value: this.element.config.displayMode.value + } + }, + data: { + product + } + }; + } + } +}); diff --git a/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/component/sw-cms-el-cross-selling.html.twig b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/component/sw-cms-el-cross-selling.html.twig new file mode 100644 index 00000000000..b993ee8685b --- /dev/null +++ b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/component/sw-cms-el-cross-selling.html.twig @@ -0,0 +1,54 @@ +{% block sw_cms_element_cross_selling %} +
+ {% block sw_cms_element_cross_selling_title %} +

+ {{ placeholder(crossSelling, 'name', crossSelling.name) }} +

+ {% endblock %} + + {% block sw_cms_element_cross_selling_content %} +
+ {% block sw_cms_element_cross_selling_arrow_left %} +
+ + +
+ {% endblock %} + + {% block sw_cms_element_cross_selling_product_holder %} +
+ + + +
+ {% endblock %} + + {% block sw_cms_element_cross_selling_arrow_right %} +
+ + +
+ {% endblock %} +
+ {% endblock %} +
+{% endblock %} diff --git a/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/component/sw-cms-el-cross-selling.scss b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/component/sw-cms-el-cross-selling.scss new file mode 100644 index 00000000000..a04c41024ff --- /dev/null +++ b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/component/sw-cms-el-cross-selling.scss @@ -0,0 +1,31 @@ +@import "~scss/variables"; + +.sw-cms-el-cross-selling { + width: 100%; + + &__title { + display: inline-block; + margin-bottom: 20px; + padding-bottom: 5px; + border-bottom: 1px solid $color-gray-900; + font-size: 14px; + } + + &__content { + display: grid; + grid-template-columns: 32px 1fr 32px; + gap: 0 8px; + } + + &__navigation { + align-self: center; + justify-self: center; + } + + &__product-holder { + width: 100%; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(185px, 1fr)); + gap: 0 32px; + } +} diff --git a/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/config/index.js b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/config/index.js new file mode 100644 index 00000000000..3df707d900e --- /dev/null +++ b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/config/index.js @@ -0,0 +1,70 @@ +import template from './sw-cms-el-config-cross-selling.html.twig'; +import './sw-cms-el-config-cross-selling.scss'; + +const { Component, Mixin } = Shopware; +const { Criteria } = Shopware.Data; + +Component.register('sw-cms-el-config-cross-selling', { + template, + + inject: ['repositoryFactory'], + + mixins: [ + Mixin.getByName('cms-element') + ], + + computed: { + productRepository() { + return this.repositoryFactory.create('product'); + }, + + productSelectContext() { + return { + ...Shopware.Context.api, + inheritance: true + }; + }, + + productCriteria() { + const criteria = new Criteria(); + criteria.addAssociation('options.group'); + + return criteria; + }, + + selectedProductCriteria() { + const criteria = new Criteria(); + criteria.addAssociation('crossSellings.assignedProducts.product'); + + return criteria; + }, + + isProductPageType() { + return this.cmsPageState.currentPage.type === 'product_detail'; + } + }, + + created() { + this.createdComponent(); + }, + + methods: { + createdComponent() { + this.initElementConfig('cross-selling'); + }, + + onProductChange(productId) { + if (!productId) { + this.element.config.product.value = null; + this.$set(this.element.data, 'product', null); + } else { + this.productRepository.get(productId, this.productSelectContext, this.selectedProductCriteria).then((product) => { + this.element.config.product.value = productId; + this.$set(this.element.data, 'product', product); + }); + } + + this.$emit('element-update', this.element); + } + } +}); diff --git a/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/config/sw-cms-el-config-cross-selling.html.twig b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/config/sw-cms-el-config-cross-selling.html.twig new file mode 100644 index 00000000000..f029e73ba90 --- /dev/null +++ b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/config/sw-cms-el-config-cross-selling.html.twig @@ -0,0 +1,105 @@ +{% block sw_cms_element_cross_selling_config %} +
+ {% block sw_cms_element_cross_selling_config_tabs %} + + + + + + {% endblock %} +
+{% endblock %} diff --git a/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/config/sw-cms-el-config-cross-selling.scss b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/config/sw-cms-el-config-cross-selling.scss new file mode 100644 index 00000000000..070e6f2d8d2 --- /dev/null +++ b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/config/sw-cms-el-config-cross-selling.scss @@ -0,0 +1,63 @@ +@import "~scss/variables"; + +.sw-cms-el-config-cross-selling { + &__products { + margin-bottom: 22px; + } + + &__tab-options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(185px, 305px)); + gap: 0 30px; + } + + .sw-select-result .sw-select-result__result-item-text { + display: block; + + .sw-highlight-text { + display: inline; + } + + .sw-cms-el-config-cross-selling__select-product-number { + display: inline; + color: $color-gray-400; + margin-left: 10px; + } + } + + .sw-label { + .sw-product-variant-info { + max-width: 180px; + } + } + + &__product-stream-performance-hint { + width: 100%; + } + + &__product-stream-preview-link-container { + margin-top: -20px; + text-align: right; + } + + &__product-stream-preview-link { + font-size: 14px; + text-decoration: none; + + &.is--disabled { + opacity: 0.5; + cursor: default; + pointer-events: none; + } + } +} + +.sw-modal.sw-cms-el-config-cross-selling__product-stream-preview-modal { + .sw-modal__body { + padding: 0; + } + + .sw-product-stream-grid-preview__toolbar { + border-top: 0; + } +} diff --git a/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/index.js b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/index.js new file mode 100644 index 00000000000..20a90ac17b5 --- /dev/null +++ b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/index.js @@ -0,0 +1,73 @@ +import './component'; +import './config'; +import './preview'; + +const Criteria = Shopware.Data.Criteria; +const criteria = new Criteria(); +criteria.addAssociation('crossSellings.assignedProducts.product'); + +Shopware.Service('cmsService').registerCmsElement({ + name: 'cross-selling', + flag: Shopware.Service('feature').isActive('FEATURE_NEXT_10078'), + hidden: !Shopware.Service('feature').isActive('FEATURE_NEXT_10078'), + label: 'sw-cms.elements.crossSelling.label', + component: 'sw-cms-el-cross-selling', + configComponent: 'sw-cms-el-config-cross-selling', + previewComponent: 'sw-cms-el-preview-cross-selling', + defaultConfig: { + product: { + source: 'static', + value: null, + required: true, + entity: { + name: 'product', + criteria: criteria + } + }, + displayMode: { + source: 'static', + value: 'standard' + }, + boxLayout: { + source: 'static', + value: 'standard' + }, + elMinWidth: { + source: 'static', + value: '200px' + } + }, + collect: function collect(elem) { + const context = { + ...Shopware.Context.api, + inheritance: true + }; + + const criteriaList = {}; + + Object.keys(elem.config).forEach((configKey) => { + if (elem.config[configKey].source === 'mapped') { + return; + } + + const entity = elem.config[configKey].entity; + + if (entity && elem.config[configKey].value) { + const entityKey = entity.name; + const entityData = { + value: [elem.config[configKey].value], + key: configKey, + searchCriteria: entity.criteria ? entity.criteria : new Criteria(), + ...entity + }; + + entityData.searchCriteria.setIds(entityData.value); + entityData.context = context; + + criteriaList[`entity-${entityKey}`] = entityData; + } + }); + + return criteriaList; + } +}); diff --git a/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/preview/index.js b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/preview/index.js new file mode 100644 index 00000000000..e298499f4fd --- /dev/null +++ b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/preview/index.js @@ -0,0 +1,8 @@ +import template from './sw-cms-el-preview-cross-selling.html.twig'; +import './sw-cms-el-preview-cross-selling.scss'; + +const { Component } = Shopware; + +Component.register('sw-cms-el-preview-cross-selling', { + template +}); diff --git a/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/preview/sw-cms-el-preview-cross-selling.html.twig b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/preview/sw-cms-el-preview-cross-selling.html.twig new file mode 100644 index 00000000000..850baf1d8c1 --- /dev/null +++ b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/preview/sw-cms-el-preview-cross-selling.html.twig @@ -0,0 +1,25 @@ +{% block sw_cms_element_cross_selling_preview %} +
+

{{ $tc('sw-cms.elements.crossSelling.preview.label.similarProduct') }}

+
+ + + {% block sw_cms_element_cross_selling_preview_box %} +
+
+ +
+ +
+
+
+
+ +
+
+ {% endblock %} + + +
+
+{% endblock %} diff --git a/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/preview/sw-cms-el-preview-cross-selling.scss b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/preview/sw-cms-el-preview-cross-selling.scss new file mode 100644 index 00000000000..76bc28674c1 --- /dev/null +++ b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/cross-selling/preview/sw-cms-el-preview-cross-selling.scss @@ -0,0 +1,68 @@ +@import "~scss/variables"; + +.sw-cms-el-preview-cross-selling { + h4 { + font-size: 14px; + margin-bottom: 10px; + border-bottom: 1px solid #d1d9e0; + padding-bottom: 5px; + } + + &__slider { + align-items: center; + display: grid; + grid-template-columns: 10px 1fr 10px; + grid-gap: 5px; + height: 100%; + } + + &-box { + height: 100%; + display: grid; + grid-template-rows: 50px 10px 20px; + align-items: center; + align-content: stretch; + padding: 0; + color: $color-gutenberg; + border-radius: 2px; + + &__image { + height: 50px; + max-width: 100%; + + img { + display: block; + object-fit: cover; + width: 100%; + height: 100%; + } + } + + &__info { + display: flex; + justify-content: space-between; + } + + &__skeleton-left, + &__skeleton-right { + height: 5px; + background: $color-gray-100; + border-radius: 5px; + } + + &__skeleton-left { + width: 70%; + } + + &__skeleton-right { + width: 20%; + } + + &__action { + background: $color-gray-900; + border-radius: 2px; + width: 100%; + height: 14px; + } + } +} diff --git a/src/Administration/Resources/app/administration/src/module/sw-cms/elements/index.js b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/index.js index ef0a4bf86a7..7de98c8e143 100644 --- a/src/Administration/Resources/app/administration/src/module/sw-cms/elements/index.js +++ b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/index.js @@ -14,3 +14,4 @@ import './sidebar-category-navigation'; import './form'; import './product-description-reviews'; import './buy-box'; +import './cross-selling'; diff --git a/src/Administration/Resources/app/administration/src/module/sw-cms/elements/product-description-reviews/component/sw-cms-el-product-description-reviews.scss b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/product-description-reviews/component/sw-cms-el-product-description-reviews.scss index 09a580e497d..f9c53ffdaec 100644 --- a/src/Administration/Resources/app/administration/src/module/sw-cms/elements/product-description-reviews/component/sw-cms-el-product-description-reviews.scss +++ b/src/Administration/Resources/app/administration/src/module/sw-cms/elements/product-description-reviews/component/sw-cms-el-product-description-reviews.scss @@ -3,7 +3,6 @@ .sw-cms-el-product-description-reviews { display: grid; align-content: flex-start; - background: $color-white; color: $color-darkgray-200; width: 100%; diff --git a/src/Administration/Resources/app/administration/src/module/sw-cms/snippet/de-DE.json b/src/Administration/Resources/app/administration/src/module/sw-cms/snippet/de-DE.json index 9627574348b..71877d7ed99 100644 --- a/src/Administration/Resources/app/administration/src/module/sw-cms/snippet/de-DE.json +++ b/src/Administration/Resources/app/administration/src/module/sw-cms/snippet/de-DE.json @@ -480,7 +480,25 @@ } }, "crossSelling": { - "label": "Cross Selling" + "label": "Cross Selling", + "preview": { + "label": { + "similarProduct": "Ähnliche Produkte" + } + }, + "config": { + "label": { + "selection": "Produkt", + "minWidth": "Mindestbreite" + }, + "placeholder": { + "selection": "Produkt auswählen ...", + "minWidth": "Mindestbreite eingeben ..." + }, + "infoText": { + "productDetailElement": "Die Produktdaten dieses Elements werden automatisch aus dem mit dem Layout verbundenen Produkt geladen. Der angezeigte Inhalt ist nur eine Beispielvorschau." + } + } } }, "blocks": { diff --git a/src/Administration/Resources/app/administration/src/module/sw-cms/snippet/en-GB.json b/src/Administration/Resources/app/administration/src/module/sw-cms/snippet/en-GB.json index 8489438b976..6bf67f72ce6 100644 --- a/src/Administration/Resources/app/administration/src/module/sw-cms/snippet/en-GB.json +++ b/src/Administration/Resources/app/administration/src/module/sw-cms/snippet/en-GB.json @@ -480,7 +480,25 @@ } }, "crossSelling": { - "label": "Cross Selling" + "label": "Cross Selling", + "preview": { + "label": { + "similarProduct": "Similar products" + } + }, + "config": { + "label": { + "selection": "Product", + "minWidth": "Minimal width" + }, + "placeholder": { + "selection": "Select product...", + "minWidth": "Enter minimal width..." + }, + "infoText": { + "productDetailElement": "This element's product data is automatically loaded by the product associated with this layout. The content just shows a sample preview." + } + } } }, "blocks": { diff --git a/src/Administration/Resources/app/administration/test/module/sw-cms/elements/cross-selling/config/sw-cms-el-config-cross-selling.spec.js b/src/Administration/Resources/app/administration/test/module/sw-cms/elements/cross-selling/config/sw-cms-el-config-cross-selling.spec.js new file mode 100644 index 00000000000..ac6063ba11b --- /dev/null +++ b/src/Administration/Resources/app/administration/test/module/sw-cms/elements/cross-selling/config/sw-cms-el-config-cross-selling.spec.js @@ -0,0 +1,101 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import 'src/module/sw-cms/mixin/sw-cms-element.mixin'; +import 'src/module/sw-cms/elements/cross-selling/config'; + +function createWrapper(customCmsElementConfig) { + const localVue = createLocalVue(); + + const productMock = { + name: 'Small Silk Heart Worms' + }; + + return shallowMount(Shopware.Component.build('sw-cms-el-config-cross-selling'), { + localVue, + propsData: { + element: { + config: { + title: { + value: '' + }, + product: { + value: 'de8de156da134dabac24257f81ff282f', + source: 'static' + }, + ...customCmsElementConfig + } + }, + defaultConfig: {} + }, + data() { + return { + cmsPageState: { + currentPage: { + type: 'landingpage' + } + } + }; + }, + stubs: { + 'sw-tabs': { + template: '
' + }, + 'sw-tabs-item': true, + 'sw-container': true, + 'sw-field': true, + 'sw-modal': true, + 'sw-entity-single-select': true, + 'sw-alert': true, + 'sw-icon': true + }, + mocks: { + $tc: (value) => value + }, + provide: { + feature: { + isActive: () => true + }, + cmsService: { + getCmsBlockRegistry: () => { + return {}; + }, + getCmsElementRegistry: () => { + return {}; + } + }, + repositoryFactory: { + create: () => { + return { + get: () => Promise.resolve(productMock), + search: () => Promise.resolve(productMock) + }; + } + } + } + }); +} + +describe('module/sw-cms/elements/cross-selling/config', () => { + it('should display a message if it is product page layout type', async () => { + const wrapper = createWrapper(); + + const productSelect = wrapper.find('sw-entity-single-select-stub'); + + expect(productSelect.exists()).toBe(true); + }); + + it('should display product select if it is not product page layout type', async () => { + const wrapper = createWrapper(); + await wrapper.setData({ + cmsPageState: { + currentPage: { + type: 'product_detail' + } + } + }); + + const alertMessage = wrapper.find('sw-alert-stub'); + + expect(alertMessage.exists()).toBe(true); + expect(alertMessage.text()).toEqual('sw-cms.elements.crossSelling.config.infoText.productDetailElement'); + }); +});