From f1fbe1a1acf7ee0f10229a49e36c33743816adcc Mon Sep 17 00:00:00 2001 From: vienthuong Date: Fri, 27 Aug 2021 14:05:28 +0700 Subject: [PATCH] First release --- CHANGELOG_de-DE.md | 7 + CHANGELOG_en-GB.md | 7 + LICENSE.md | 21 ++ README.md | 30 ++ composer.json | 28 ++ .../dist/storefront/js/sas-variant-switch.js | 1 + src/Resources/app/storefront/src/main.js | 12 + .../offcanvas-cart-switch-option.plugin.js | 37 ++ .../src/plugin/variant-hover-switch.plugin.js | 204 +++++++++++ .../app/storefront/src/scss/_product-box.scss | 85 +++++ .../app/storefront/src/scss/base.scss | 1 + src/Resources/config/config.xml | 44 +++ src/Resources/config/plugin.png | Bin 0 -> 4552 bytes src/Resources/config/routes.xml | 9 + src/Resources/config/services.xml | 37 ++ .../group/box-card-group-select.html.twig | 27 ++ .../checkout/offcanvas-item.html.twig | 57 ++++ .../product/card/box-standard.html.twig | 33 ++ .../card/group/box-card-group-input.html.twig | 88 +++++ .../group/box-card-group-select.html.twig | 27 ++ .../views/storefront/layout/meta.html.twig | 9 + .../layout/variant-switch-config.html.twig | 21 ++ .../page/checkout/checkout-item.html.twig | 63 ++++ .../checkout/confirm/confirm-item.html.twig | 28 ++ src/SasVariantSwitch.php | 14 + .../Controller/VariantSwitchController.php | 192 +++++++++++ .../Event/ProductBoxLoadedEvent.php | 44 +++ .../ProductListingConfigurationLoader.php | 317 ++++++++++++++++++ src/Subscriber/CartPageLoadedSubscriber.php | 130 +++++++ .../ProductListingResultLoadedSubscriber.php | 63 ++++ 30 files changed, 1636 insertions(+) create mode 100644 CHANGELOG_de-DE.md create mode 100644 CHANGELOG_en-GB.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 src/Resources/app/storefront/dist/storefront/js/sas-variant-switch.js create mode 100644 src/Resources/app/storefront/src/main.js create mode 100644 src/Resources/app/storefront/src/plugin/offcanvas-cart-switch-option.plugin.js create mode 100644 src/Resources/app/storefront/src/plugin/variant-hover-switch.plugin.js create mode 100644 src/Resources/app/storefront/src/scss/_product-box.scss create mode 100644 src/Resources/app/storefront/src/scss/base.scss create mode 100644 src/Resources/config/config.xml create mode 100644 src/Resources/config/plugin.png create mode 100644 src/Resources/config/routes.xml create mode 100644 src/Resources/config/services.xml create mode 100644 src/Resources/views/storefront/component/checkout/group/box-card-group-select.html.twig create mode 100644 src/Resources/views/storefront/component/checkout/offcanvas-item.html.twig create mode 100644 src/Resources/views/storefront/component/product/card/box-standard.html.twig create mode 100644 src/Resources/views/storefront/component/product/card/group/box-card-group-input.html.twig create mode 100644 src/Resources/views/storefront/component/product/card/group/box-card-group-select.html.twig create mode 100644 src/Resources/views/storefront/layout/meta.html.twig create mode 100644 src/Resources/views/storefront/layout/variant-switch-config.html.twig create mode 100644 src/Resources/views/storefront/page/checkout/checkout-item.html.twig create mode 100644 src/Resources/views/storefront/page/checkout/confirm/confirm-item.html.twig create mode 100644 src/SasVariantSwitch.php create mode 100644 src/Storefront/Controller/VariantSwitchController.php create mode 100644 src/Storefront/Event/ProductBoxLoadedEvent.php create mode 100644 src/Storefront/Page/ProductListingConfigurationLoader.php create mode 100644 src/Subscriber/CartPageLoadedSubscriber.php create mode 100644 src/Subscriber/ProductListingResultLoadedSubscriber.php diff --git a/CHANGELOG_de-DE.md b/CHANGELOG_de-DE.md new file mode 100644 index 0000000..d488d9a --- /dev/null +++ b/CHANGELOG_de-DE.md @@ -0,0 +1,7 @@ +# 1.0.0 - Erste Veröffentlichung +- Variantenschalter auf der Produktliste anzeigen +- Variantenschalter beim Hovern über eine Produkteigenschaft auf der Produktliste +- Variantenschalter auf dem Off-Canvas-Warenkorb anzeigen +- Variantenschalter auf der Warenkorbseite anzeigen +- Variantenschalter auf der Bestätigungsseite der Kaufabwicklung anzeigen +- Alle diese Optionen sind konfigurierbar diff --git a/CHANGELOG_en-GB.md b/CHANGELOG_en-GB.md new file mode 100644 index 0000000..50c212c --- /dev/null +++ b/CHANGELOG_en-GB.md @@ -0,0 +1,7 @@ +# 1.0.0 - First release +- Show variant switch on product listing card +- Variant switch when hovering a variant property on product listing +- Show variant switch on off-canvas cart +- Show variant switch on cart page +- Show variant switch on checkout confirm page +- All these options are configurable diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..ec4f833 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# The MIT License (MIT) + +Copyright (c) 2020 vienthuong + +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f25a07 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# Variant switch for Shopware 6 + +## A plugin for [Shopware 6](https://github.com/shopware/platform) + +## [Demo](https://imgur.com/gallery/FGEqepO): + +![](https://media.giphy.com/media/jPe0KX39d0oT5sxtqR/giphy.gif) + +![](https://i.imgur.com/H5ndVwE.png) + +## Features + +- Show variant switch on product listing card +- Variant switch when hovering a variant property on product listing +- Show variant switch on off-canvas cart +- Show variant switch on cart page +- Show variant switch on checkout confirm page +- All these options are configurable + +## Requirements + +| Version | Requirements | +|--------- |---------------------------- | +| 1.0.0 | Shopware 6.4 >= | + +## License + +Plugin's Icon by [flaticon](https://www.flaticon.com). + +The plugin is released under MIT. For a full overview check the [LICENSE](./LICENSE) file. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..41e3570 --- /dev/null +++ b/composer.json @@ -0,0 +1,28 @@ +{ + "name": "sas/variant-switch", + "description": "Variant switch", + "type": "shopware-platform-plugin", + "license": "MIT", + "autoload": { + "psr-4": { + "SasVariantSwitch\\": "src" + } + }, + "require": { + "shopware/core": "^6.3", + "shopware/storefront": "^6.3" + }, + "extra": { + "shopware-plugin-class": "SasVariantSwitch\\SasVariantSwitch", + "plugin-icon": "src/Resources/config/plugin.png", + "copyright": "(c) by Thuong Le", + "description": { + "de-DE": "Schnellwechsel der Produktvariante", + "en-GB": "Quick switch product's variant" + }, + "label": { + "de-DE": "Variant Schalter", + "en-GB": "Variant switch" + } + } +} diff --git a/src/Resources/app/storefront/dist/storefront/js/sas-variant-switch.js b/src/Resources/app/storefront/dist/storefront/js/sas-variant-switch.js new file mode 100644 index 0000000..e741568 --- /dev/null +++ b/src/Resources/app/storefront/dist/storefront/js/sas-variant-switch.js @@ -0,0 +1 @@ +(window.webpackJsonp=window.webpackJsonp||[]).push([["sas-variant-switch"],{bK22:function(e,t,n){"use strict";n.d(t,"a",(function(){return h})),n.d(t,"b",(function(){return y}));var r=n("41MI"),i=n("+F6M"),o=n("KeF5"),a=n("ERap");function s(e){return(s="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function u(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function c(e,t){for(var n=0;n0}},{key:"_openOffcanvas",value:function(e,t){setTimeout((function(){o.c.create((function(){e.classList.add(p),window.history.pushState("offcanvas-open",""),"function"==typeof t&&t()}))}),75)}},{key:"_registerEvents",value:function(e,t){var n=this,i=r.a.isTouchDevice()?"touchstart":"click";if(e){document.addEventListener(o.a.ON_CLICK,(function e(){n.close(t),document.removeEventListener(o.a.ON_CLICK,e)}))}window.addEventListener("popstate",this.close.bind(this,t),{once:!0});var s=document.querySelectorAll(".".concat("js-offcanvas-close"));a.a.iterate(s,(function(e){return e.addEventListener(i,n.close.bind(n,t))}))}},{key:"_removeExistingOffCanvas",value:function(){var e=this.getOffCanvas();return a.a.iterate(e,(function(e){return e.remove()}))}},{key:"_getPositionClass",value:function(e){return"is-".concat(e)}},{key:"_createOffCanvas",value:function(e,t,n){var r=document.createElement("div");if(r.classList.add(f),r.classList.add(this._getPositionClass(e)),!0===t&&r.classList.add("is-fullwidth"),n){var i=s(n);if("string"===i)r.classList.add(n);else{if(!Array.isArray(n))throw new Error('The type "'.concat(i,'" is not supported. Please pass an array or a string.'));n.forEach((function(e){r.classList.add(e)}))}}return document.body.appendChild(r),r}}]),e}(),h=Object.freeze(new d),y=function(){function e(){u(this,e)}return l(e,null,[{key:"open",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:null,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"left",r=!(arguments.length>3&&void 0!==arguments[3])||arguments[3],i=arguments.length>4&&void 0!==arguments[4]?arguments[4]:v,o=arguments.length>5&&void 0!==arguments[5]&&arguments[5],a=arguments.length>6&&void 0!==arguments[6]?arguments[6]:"";h.open(e,t,n,r,i,o,a)}},{key:"setContent",value:function(e){var t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1],n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:v;h.setContent(e,t,n)}},{key:"setAdditionalClassName",value:function(e){h.setAdditionalClassName(e)}},{key:"close",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:v;h.close(e)}},{key:"exists",value:function(){return h.exists()}},{key:"getOffCanvas",value:function(){return h.getOffCanvas()}},{key:"REMOVE_OFF_CANVAS_DELAY",value:function(){return v}}]),e}()},lpb5:function(e,t,n){"use strict";n.d(t,"a",(function(){return d}));var r=n("bK22"),i=n("k8s9"),o=n("5lm9");function a(e){return(a="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function s(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function u(e,t){for(var n=0;n0&&void 0!==arguments[0]&&arguments[0],t=arguments.length>1&&void 0!==arguments[1]&&arguments[1],n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:null,i=arguments.length>3&&void 0!==arguments[3]?arguments[3]:"left",o=!(arguments.length>4&&void 0!==arguments[4])||arguments[4],a=arguments.length>5&&void 0!==arguments[5]?arguments[5]:r.b.REMOVE_OFF_CANVAS_DELAY(),s=arguments.length>6&&void 0!==arguments[6]&&arguments[6],u=arguments.length>7&&void 0!==arguments[7]?arguments[7]:"";if(!e)throw new Error("A url must be given!");r.a._removeExistingOffCanvas();var c=r.a._createOffCanvas(i,s,u);this.setContent(e,t,n,o,a),r.a._openOffcanvas(c)}},{key:"setContent",value:function(e,n,r,a,s){var u=this,c=new i.a;l(f(t),"setContent",this).call(this,'
'.concat(o.a.getTemplate(),"
"),a,s),v&&v.abort();var p=function(e){l(f(t),"setContent",u).call(u,e,a,s),"function"==typeof r&&r(e)};v=n?c.post(e,n,t.executeCallback.bind(this,p)):c.get(e,t.executeCallback.bind(this,p))}},{key:"executeCallback",value:function(e,t){"function"==typeof e&&e(t),window.PluginManager.initializePlugins()}}],(a=null)&&u(n.prototype,a),d&&u(n,d),t}(r.b)},oG4O:function(e,t,n){"use strict";n.r(t);var r=n("FGIj"),i=n("k8s9"),o=n("u0Tz"),a=n("gHbT"),s=n("ERap"),u=n("NWgQ"),c=n.n(u);function l(e){return(l="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function f(e){return function(e){if(Array.isArray(e))return p(e)}(e)||function(e){if("undefined"!=typeof Symbol&&Symbol.iterator in Object(e))return Array.from(e)}(e)||function(e,t){if(!e)return;if("string"==typeof e)return p(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);"Object"===n&&e.constructor&&(n=e.constructor.name);if("Map"===n||"Set"===n)return Array.from(e);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return p(e,t)}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function p(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n select.addEventListener('change', this._onSwitchLineItemOption.bind(this))); + } + } + + _onSwitchLineItemOption(event) { + const select = event.target; + const form = select.closest('form'); + const switchedInput = form.querySelector('.form-switched'); + switchedInput.value = event.target.id; + + const selector = this.options.cartItemSelector; + + this.$emitter.publish('onSwitchLineItemOption'); + + this._fireRequest(form, selector); + } +} diff --git a/src/Resources/app/storefront/src/plugin/variant-hover-switch.plugin.js b/src/Resources/app/storefront/src/plugin/variant-hover-switch.plugin.js new file mode 100644 index 0000000..39bdcd4 --- /dev/null +++ b/src/Resources/app/storefront/src/plugin/variant-hover-switch.plugin.js @@ -0,0 +1,204 @@ +import Plugin from 'src/plugin-system/plugin.class'; +import HttpClient from 'src/service/http-client.service'; +import ElementLoadingIndicatorUtil from 'src/utility/loading-indicator/element-loading-indicator.util'; +import DomAccess from 'src/helper/dom-access.helper'; +import Iterator from 'src/helper/iterator.helper'; +import queryString from 'query-string'; + +export default class VariantHoverSwitchPlugin extends Plugin { + static options = { + radioFieldSelector: '.sas-product-configurator-option-input', + selectFieldSelector: '.sas-product-configurator-select-input', + urlAttribute: 'data-url', + cardType: 'standard' + }; + + init() { + this._httpClient = new HttpClient(); + this._radioFields = DomAccess.querySelectorAll(this.el, this.options.radioFieldSelector, false); + this._selectFields = DomAccess.querySelectorAll(this.el, this.options.selectFieldSelector, false); + + this._productBox = this.el.closest('.product-box'); + window.variantResponseCached = window.variantResponseCached || {}; + this._hoveringValue = null; + + this._preserveCurrentValues(); + this._registerEvents(); + } + + /** + * saves the current value on each form element + * to be able to retrieve it once it has changed + * + * @private + */ + _preserveCurrentValues() { + if(this._radioFields) { + Iterator.iterate(this._radioFields, field => { + if (VariantHoverSwitchPlugin._isFieldSerializable(field)) { + if (field.dataset) { + field.dataset.variantSwitchValue = field.value; + } + } + }); + } + } + + /** + * register all needed events + * + * @private + */ + _registerEvents() { + if(this._radioFields) { + Iterator.iterate(this._radioFields, field => { + field.addEventListener('change', event => this._onChange(event.target)); + const label = field.parentElement.querySelector('label'); + + if (window.sasPreviewVariantOnHover) { + label.addEventListener('mouseenter', event => { + const input = event.target.parentElement.querySelector('input'); + this._hoveringValue = input.value; + + if (input && !input.checked) { + setTimeout(() => { + if (this._hoveringValue && this._hoveringValue === input.value) { + input.click(); + } + }, 200) + } + }); + + label.addEventListener('mouseleave', event => { + this._hoveringValue = null; + }); + } + }); + } + + if(this._selectFields) { + Iterator.iterate(this._selectFields, field => { + field.addEventListener('change', event => this._onChange(event.target)); + }); + } + } + + /** + * callback when the form has changed + * + * @param element + * @private + */ + _onChange(element) { + const switchedOptionId = this._getSwitchedOptionId(element); + const selectedOptions = this._getFormValue(); + this._preserveCurrentValues(); + + this.$emitter.publish('onChange'); + + const query = { + switched: switchedOptionId, + options: JSON.stringify(selectedOptions), + cardType: this.options.cardType + }; + + ElementLoadingIndicatorUtil.create(this.el); + + let url = DomAccess.getAttribute(element, this.options.urlAttribute); + + url = url + '?' + queryString.stringify({ ...query }); + + if (window.variantResponseCached[url]) { + if (this._productBox) { + this._productBox.outerHTML = window.variantResponseCached[url]; + } + + ElementLoadingIndicatorUtil.remove(this.el); + + window.PluginManager.initializePlugins(); + + return; + } + + this._httpClient.get(url, (response) => { + window.variantResponseCached[url] = response; + if (this._productBox) { + this._productBox.outerHTML = response; + } + ElementLoadingIndicatorUtil.remove(this.el); + + window.PluginManager.initializePlugins() + }); + } + + /** + * returns the option id of the recently switched field + * + * @param field + * @returns {*} + * @private + */ + _getSwitchedOptionId(field) { + if (!VariantHoverSwitchPlugin._isFieldSerializable(field)) { + return false; + } + + return DomAccess.getAttribute(field, 'data-name'); + } + + /** + * returns the current selected + * variant options from the form + * + * @private + */ + _getFormValue() { + const serialized = {}; + if(this._radioFields) { + Iterator.iterate(this._radioFields, field => { + if (VariantHoverSwitchPlugin._isFieldSerializable(field)) { + if (field.checked) { + serialized[DomAccess.getAttribute(field, 'data-name')] = field.value; + } + } + }); + } + + if(this._selectFields) { + Iterator.iterate(this._selectFields, field => { + if (VariantHoverSwitchPlugin._isFieldSerializable(field)) { + const selectedOption = [...field.options].find(option => option.selected); + serialized[DomAccess.getAttribute(field, 'data-name')] = selectedOption.value; + } + }); + } + + return serialized; + } + + /** + * checks id the field is a value field + * and therefore serializable + * + * @param field + * @returns {boolean|*} + * + * @private + */ + static _isFieldSerializable(field) { + return !field.name || field.disabled || ['file', 'reset', 'submit', 'button'].indexOf(field.type) === -1; + } + + /** + * disables all form fields on the form submit + * + * @private + */ + _disableFields() { + Iterator.iterate(this._radioFields, field => { + if (field.classList) { + field.classList.add('disabled', 'disabled'); + } + }); + } +} diff --git a/src/Resources/app/storefront/src/scss/_product-box.scss b/src/Resources/app/storefront/src/scss/_product-box.scss new file mode 100644 index 0000000..41a0354 --- /dev/null +++ b/src/Resources/app/storefront/src/scss/_product-box.scss @@ -0,0 +1,85 @@ +.product-box { + .card-body { + display: flex; + flex-direction: column; + } +} + +.sas-product-configurator-group { + margin-bottom: $spacer-md; + display: inline-block; + margin-right: 5px; + + .custom-select { + width: auto; + } +} + +.sas-product-configurator-group-title { + font-weight: $font-weight-bold; + margin-bottom: $spacer-sm; +} + +.sas-product-configurator-options { + display: flex; + flex-wrap: wrap; + flex-direction: row; +} + +.sas-product-configurator-option { + display: inline-flex; + margin-right: $spacer-sm; +} + +.sas-product-configurator-option-input { + display: none; + + + .sas-product-configurator-option-label { + align-items: center; + cursor: pointer; + border: 1px dashed $dark; + border-radius: $border-radius; + box-shadow: inset 3px 3px 0 $white, inset -3px -3px 0 $white; + background-color: $white; + display: inline-flex; + justify-content: center; + height: 60px; + min-width: 60px; + opacity: 0.35; + padding: 3px; + transition: border-color 0.45s cubic-bezier(0.3, 0, 0.15, 1), background-color 0.45s cubic-bezier(0.3, 0, 0.15, 1); + + &.is-display-text { + box-shadow: none; + height: auto; + padding: 5px 10px; + } + } + + &.is-combinable + .sas-product-configurator-option-label { + opacity: 1; + border: 1px solid $border-color; + } + + &:checked + .sas-product-configurator-option-label { + border: 1px solid $primary; + + &.is-display-text { + background-color: $primary; + color: $white; + } + } + + &.is-combinable + .sas-product-configurator-option-label, + + .sas-product-configurator-option-label { + &:hover, + &:active, + &:focus { + border: 1px solid $primary; + } + } +} + +.sas-product-configurator-option-image { + height: 100%; +} diff --git a/src/Resources/app/storefront/src/scss/base.scss b/src/Resources/app/storefront/src/scss/base.scss new file mode 100644 index 0000000..d33e20b --- /dev/null +++ b/src/Resources/app/storefront/src/scss/base.scss @@ -0,0 +1 @@ +@import './_product-box.scss'; diff --git a/src/Resources/config/config.xml b/src/Resources/config/config.xml new file mode 100644 index 0000000..32c025a --- /dev/null +++ b/src/Resources/config/config.xml @@ -0,0 +1,44 @@ + + + + Variant Switch Configuration + Variante Schalterkonfiguration + + + showOnProductCard + + + true + + + + previewVariantOnHover + + + true + + + + showOnOffCanvasCart + + + true + + + + showOnCartPage + + + true + + + + showOnCheckoutConfirmPage + + + true + + + + diff --git a/src/Resources/config/plugin.png b/src/Resources/config/plugin.png new file mode 100644 index 0000000000000000000000000000000000000000..fd04af3854b3a2f7cefb65d7fbee8ea9fea1cd19 GIT binary patch literal 4552 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 + + + + + diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml new file mode 100644 index 0000000..b78fe6c --- /dev/null +++ b/src/Resources/config/services.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Resources/views/storefront/component/checkout/group/box-card-group-select.html.twig b/src/Resources/views/storefront/component/checkout/group/box-card-group-select.html.twig new file mode 100644 index 0000000..6591a28 --- /dev/null +++ b/src/Resources/views/storefront/component/checkout/group/box-card-group-select.html.twig @@ -0,0 +1,27 @@ +{% block component_offcanvas_product_details_variant_switch_configurator_group_select %} + {% block component_offcanvas_product_details_variant_switch_configurator_group_select_title %} + + {% block component_offcanvas_product_details_variant_switch_configurator_select %} + + {% endblock %} + {% endblock %} +{% endblock %} diff --git a/src/Resources/views/storefront/component/checkout/offcanvas-item.html.twig b/src/Resources/views/storefront/component/checkout/offcanvas-item.html.twig new file mode 100644 index 0000000..1ba4806 --- /dev/null +++ b/src/Resources/views/storefront/component/checkout/offcanvas-item.html.twig @@ -0,0 +1,57 @@ +{% sw_extends '@Storefront/storefront/component/checkout/offcanvas-item.html.twig' %} + +{% block cart_item_variant_characteristics %} + {% if lineItem.type != 'product' or not config('SasVariantSwitch.config.showOnOffCanvasCart') %} + {{ parent() }} + {% endif %} +{% endblock %} + +{% block component_offcanvas_product_details_features %} + {{ parent() }} + {% if lineItem.type == 'product' and config('SasVariantSwitch.config.showOnOffCanvasCart') %} + {% block component_offcanvas_product_details_variant_switch %} + {% if lineItem.extensions.groups %} + {% set optionIds = lineItem.payload.optionIds %} + {% set parentId = lineItem.payload.parentId %} + +
+
+ + {% block component_offcanvas_product_details_variant_switch_form_csrf %} + {{ sw_csrf('sas.frontend.lineItem.variant.switch') }} + {% endblock %} + + {% block component_offcanvas_product_details_variant_switch_form_redirect %} + + {% endblock %} + + {% block component_offcanvas_product_details_variant_switch_parent_id %} + + {% endblock %} + + {% block component_offcanvas_product_details_variant_switch_switched %} + + {% endblock %} + + {% for group in lineItem.extensions.groups %} + {% set groupIdentifier = [lineItem.referencedId, group.id]|join('-') %} + + {% block component_offcanvas_product_details_variant_switch_configurator_group %} +
+ {% sw_include '@Storefront/storefront/component/checkout/group/box-card-group-select.html.twig' %} +
+ {% endblock %} + {% endfor %} +
+
+ {% endif %} + {% endblock %} + {% endif %} +{% endblock %} + diff --git a/src/Resources/views/storefront/component/product/card/box-standard.html.twig b/src/Resources/views/storefront/component/product/card/box-standard.html.twig new file mode 100644 index 0000000..d1caa8c --- /dev/null +++ b/src/Resources/views/storefront/component/product/card/box-standard.html.twig @@ -0,0 +1,33 @@ +{% sw_extends '@Storefront/storefront/component/product/card/box-standard.html.twig' %} + +{% block component_product_box_variant_characteristics %} + {{ parent() }} + + {% if config('SasVariantSwitch.config.showOnProductCard') %} + {% block page_product_detail_configurator_groups %} + {% set variantHoverSwitchOptions = { + cardType: 'standard' + } %} + + {% block component_offcanvas_product_details_variant_switch %} + {% if product.extensions.groups %} +
+ {% for group in product.extensions.groups %} + {% set groupIdentifier = [product.id, group.id]|join('-') %} + {% block page_product_detail_configurator_group %} +
+ {% if group.displayType == 'select' %} + {% sw_include '@Storefront/storefront/component/product/card/group/box-card-group-select.html.twig' %} + {% else %} + {% sw_include '@Storefront/storefront/component/product/card/group/box-card-group-input.html.twig' %} + {% endif %} +
+ {% endblock %} + {% endfor %} +
+ {% endif %} + {% endblock %} + + {% endblock %} + {% endif %} +{% endblock %} diff --git a/src/Resources/views/storefront/component/product/card/group/box-card-group-input.html.twig b/src/Resources/views/storefront/component/product/card/group/box-card-group-input.html.twig new file mode 100644 index 0000000..676da88 --- /dev/null +++ b/src/Resources/views/storefront/component/product/card/group/box-card-group-input.html.twig @@ -0,0 +1,88 @@ +{% block component_product_box_configurator_group_select %} + {% block component_product_box_configurator_group_title %} +
+ {% block component_product_box_configurator_group_select_title_text %} + {{ group.translated.name }} + {% endblock %} +
+ {% endblock %} + + {% block component_product_box_configurator_options %} +
+ {% for option in group.options %} + + {% set optionIdentifier = [groupIdentifier, option.id]|join('-') %} + {% set isActive = false %} + {% set isCombinableCls = 'is-combinable' %} + + {% if option.id in product.optionIds %} + {% set isActive = true %} + {% endif %} + + {% if not option.combinable %} + {% set isCombinableCls = false %} + {% endif %} + + {% if option.configuratorSetting.media %} + {% set displayType = 'media' %} + {% set media = option.configuratorSetting.media %} + {% else %} + {% set displayType = group.displayType %} + {% if option.media %} + {% set media = option.media %} + {% else %} + {% set media = false %} + {% endif %} + {% endif %} + + {% block component_product_box_configurator_option %} +
+ {% block component_product_box_configurator_option_radio %} + + + {% block component_product_box_configurator_option_radio_label %} + + {% endblock %} + {% endblock %} +
+ {% endblock %} + {% endfor %} +
+ {% endblock %} +{% endblock %} diff --git a/src/Resources/views/storefront/component/product/card/group/box-card-group-select.html.twig b/src/Resources/views/storefront/component/product/card/group/box-card-group-select.html.twig new file mode 100644 index 0000000..c25e36d --- /dev/null +++ b/src/Resources/views/storefront/component/product/card/group/box-card-group-select.html.twig @@ -0,0 +1,27 @@ +{% block component_product_box_configurator_group_select %} + {% block component_product_box_configurator_group_select_title %} + + {% block component_product_box_configurator_select %} + + {% endblock %} + {% endblock %} +{% endblock %} diff --git a/src/Resources/views/storefront/layout/meta.html.twig b/src/Resources/views/storefront/layout/meta.html.twig new file mode 100644 index 0000000..9ee0525 --- /dev/null +++ b/src/Resources/views/storefront/layout/meta.html.twig @@ -0,0 +1,9 @@ +{% sw_extends '@Storefront/storefront/layout/meta.html.twig' %} + +{% block layout_head_javascript_feature %} + {{ parent() }} + + {% block sas_layout_head_javascript_variant_switch %} + {% sw_include '@Storefront/storefront/layout/variant-switch-config.html.twig' %} + {% endblock %} +{% endblock %} diff --git a/src/Resources/views/storefront/layout/variant-switch-config.html.twig b/src/Resources/views/storefront/layout/variant-switch-config.html.twig new file mode 100644 index 0000000..88cd1b7 --- /dev/null +++ b/src/Resources/views/storefront/layout/variant-switch-config.html.twig @@ -0,0 +1,21 @@ + diff --git a/src/Resources/views/storefront/page/checkout/checkout-item.html.twig b/src/Resources/views/storefront/page/checkout/checkout-item.html.twig new file mode 100644 index 0000000..a5e8462 --- /dev/null +++ b/src/Resources/views/storefront/page/checkout/checkout-item.html.twig @@ -0,0 +1,63 @@ +{% sw_extends '@Storefront/storefront/page/checkout/checkout-item.html.twig' %} + +{% block page_checkout_item_info_variant_characteristics %} + {% if lineItem.type != 'product' or not config('SasVariantSwitch.config.showOnCartPage') %} + {{ parent() }} + {% endif %} +{% endblock %} + +{% block page_checkout_item_info_features %} + {{ parent() }} + + {% block page_checkout_item_info_features_variant_switch_container %} + + {% if lineItem.type == 'product' and config('SasVariantSwitch.config.showOnCartPage') %} + + {% block page_checkout_item_info_features_variant_switch %} + {% if lineItem.extensions.groups %} + {% set optionIds = lineItem.payload.optionIds %} + {% set parentId = lineItem.payload.parentId %} + +
+
+ + {% block page_checkout_item_info_features_variant_switch_form_csrf %} + {{ sw_csrf('sas.frontend.lineItem.variant.switch') }} + {% endblock %} + + {% block page_checkout_item_quantity_redirect %} + + {% endblock %} + + {% block page_checkout_item_info_features_variant_switch_parent_id %} + + {% endblock %} + + {% block page_checkout_item_info_features_variant_switch_switched %} + + {% endblock %} + + {% for group in lineItem.extensions.groups %} + {% set groupIdentifier = [lineItem.referencedId, group.id]|join('-') %} + + {% block page_checkout_item_info_features_variant_switch_configurator_group %} +
+ {% sw_include '@Storefront/storefront/component/checkout/group/box-card-group-select.html.twig' %} +
+ {% endblock %} + {% endfor %} +
+
+ {% endif %} + {% endblock %} + + {% endif %} + {% endblock %} +{% endblock %} diff --git a/src/Resources/views/storefront/page/checkout/confirm/confirm-item.html.twig b/src/Resources/views/storefront/page/checkout/confirm/confirm-item.html.twig new file mode 100644 index 0000000..47f1d06 --- /dev/null +++ b/src/Resources/views/storefront/page/checkout/confirm/confirm-item.html.twig @@ -0,0 +1,28 @@ +{% sw_extends '@Storefront/storefront/page/checkout/confirm/confirm-item.html.twig' %} + +{% block page_checkout_item_info_variant_characteristics %} + {% if lineItem.type != 'product' or not config('SasVariantSwitch.config.showOnCheckoutConfirmPage') %} +
+ + {% for option in lineItem.payload.options %} + {{ option.group }}: + {{ option.option }} + + {% if lineItem.payload.options|last != option %} + {{ " | " }} + {% endif %} + {% endfor %} + +
+ {% endif %} +{% endblock %} + +{% block page_checkout_item_info_features_variant_switch_container %} + {% if lineItem.type == 'product' and config('SasVariantSwitch.config.showOnCheckoutConfirmPage') %} + + {% block page_checkout_item_info_features_variant_switch %} + {{ parent() }} + {% endblock %} + + {% endif %} +{% endblock %} diff --git a/src/SasVariantSwitch.php b/src/SasVariantSwitch.php new file mode 100644 index 0000000..67efb81 --- /dev/null +++ b/src/SasVariantSwitch.php @@ -0,0 +1,14 @@ +combinationFinder = $combinationFinder; + $this->productRepository = $productRepository; + $this->cartService = $cartService; + $this->dispatcher = $dispatcher; + $this->lineItemFactory = $lineItemFactory; + } + + /** + * @HttpCache + * @Route("/sas/line-item/switch-variant/{id}", name="sas.frontend.lineItem.variant.switch", methods={"POST"}, defaults={"XmlHttpRequest": true}) + */ + public function switchLineItemVariant(Cart $cart, string $id, Request $request, SalesChannelContext $context): Response + { + try { + $options = $request->get('options'); + + if ($options === null) { + throw new \InvalidArgumentException('options field is required'); + } + + $productId = $request->get('parentId'); + + if ($productId === null) { + throw new \InvalidArgumentException('parentId field is required'); + } + + if (!$cart->has($id)) { + throw new LineItemNotFoundException($id); + } + + $lineItem = $cart->get($id); + + if ($lineItem->getType() !== LineItem::PRODUCT_LINE_ITEM_TYPE) { + throw new \InvalidArgumentException('Line item is not a product'); + } + + $switchedOption = $request->query->has('switched') ? (string) $request->query->get('switched') : null; + + try { + $redirect = $this->combinationFinder->find($productId, $switchedOption, $options, $context); + + $productId = $redirect->getVariantId(); + } catch (ProductNotFoundException $productNotFoundException) { + //nth + + return new Response(); + } + + $lineItems = $cart->getLineItems(); + $newLineItems = new LineItemCollection(); + + /** @var LineItem $lineItem */ + foreach ($lineItems as $lineItem) { + if ($lineItem->getId() === $id) { + $item = [ + 'id' => $productId, + 'referencedId' => $productId, + 'stackable' => $lineItem->isStackable(), + 'removable' => $lineItem->isRemovable(), + 'quantity' => $lineItem->getQuantity(), + 'type' => LineItem::PRODUCT_LINE_ITEM_TYPE + ]; + + $newLineItem = $this->lineItemFactory->create($item, $context); + + if ($newLineItems->has($productId)) { + $newLineItem->setQuantity($lineItem->getQuantity() + $newLineItems->get($productId)->getQuantity()); + } + + $newLineItems->set($productId, $newLineItem); + continue; + } + + $newLineItems->add($lineItem); + } + + $cart->setLineItems($newLineItems); + $cart = $this->cartService->recalculate($cart, $context); + + if (!$this->traceErrors($cart)) { + $this->addFlash(self::SUCCESS, $this->trans('checkout.cartUpdateSuccess')); + } + } catch (\Exception $exception) { + $this->addFlash(self::DANGER, $this->trans('error.message-default')); + } + + return $this->createActionResponse($request); + } + + /** + * @HttpCache + * @Route("/sas/switch-variant/{productId}", name="sas.frontend.variant.switch", methods={"GET"}, defaults={"XmlHttpRequest": true}) + */ + public function switchVariant(string $productId, Request $request, SalesChannelContext $context): Response + { + $switchedOption = $request->query->has('switched') ? (string) $request->query->get('switched') : null; + + $cardType = $request->query->has('cardType') ? (string) $request->query->get('cardType') : 'standard'; + + $options = (string) $request->query->get('options'); + $newOptions = $options !== '' ? json_decode($options, true) : []; + + try { + $redirect = $this->combinationFinder->find($productId, $switchedOption, $newOptions, $context); + + $productId = $redirect->getVariantId(); + } catch (ProductNotFoundException $productNotFoundException) { + //nth + + return new Response(); + } + + $criteria = (new Criteria([$productId])) + ->addAssociation('manufacturer.media') + ->addAssociation('options.group') + ->addAssociation('properties.group') + ->addAssociation('mainCategories.category') + ->addAssociation('media'); + + $criteria->addExtension('sortings', new ProductSortingCollection()); + + $result = $this->productRepository->search($criteria, $context); + + $product = $result->get($productId); + + $this->dispatcher->dispatch( + new ProductBoxLoadedEvent($request, $product, $context) + ); + + return $this->renderStorefront("@Storefront/storefront/component/product/card/box-$cardType.html.twig", [ + 'product' => $product, + 'layout' => $cardType + ]); + } + + private function traceErrors(Cart $cart): bool + { + if ($cart->getErrors()->count() <= 0) { + return false; + } + + $this->addCartErrors($cart, function (Error $error) { + return $error->isPersistent(); + }); + + return true; + } +} diff --git a/src/Storefront/Event/ProductBoxLoadedEvent.php b/src/Storefront/Event/ProductBoxLoadedEvent.php new file mode 100644 index 0000000..0bcd547 --- /dev/null +++ b/src/Storefront/Event/ProductBoxLoadedEvent.php @@ -0,0 +1,44 @@ +request = $request; + $this->context = $context; + $this->product = $product; + } + + public function getSalesChannelContext(): SalesChannelContext + { + return $this->context; + } + + public function getContext(): Context + { + return $this->context->getContext(); + } + + public function getProduct(): SalesChannelProductEntity + { + return $this->product; + } + + public function getRequest(): Request + { + return $this->request; + } +} diff --git a/src/Storefront/Page/ProductListingConfigurationLoader.php b/src/Storefront/Page/ProductListingConfigurationLoader.php new file mode 100644 index 0000000..f6c53dc --- /dev/null +++ b/src/Storefront/Page/ProductListingConfigurationLoader.php @@ -0,0 +1,317 @@ +configuratorRepository = $configuratorRepository; + $this->connection = $connection; + } + + public function loadListing(ProductCollection $products, SalesChannelContext $context): void + { + $productSettings = $this->loadSettings($products, $context); + + if (empty($productSettings)) { + return; + } + + $productIds = array_filter($products->map(function (SalesChannelProductEntity $product) { + return $product->getParentId() ?? $product->getId(); + })); + + $allCombinations = $this->loadCombinations($productIds, $context->getContext()); + + /** @var SalesChannelProductEntity $product */ + foreach ($products as $product) { + if ($product->getConfiguratorSettings() !== null || !$product->getParentId() || empty($productSettings[$product->getParentId()])) { + $product->addExtension('groups', new PropertyGroupCollection()); + + continue; + } + + $groups = $this->sortSettings($productSettings[$product->getParentId()], $product); + + $combinations = $allCombinations[$product->getParentId()]; + + $current = $this->buildCurrentOptions($product, $groups); + + foreach ($groups as $group) { + $options = $group->getOptions(); + if ($options === null) { + continue; + } + + foreach ($options as $option) { + $combinable = $this->isCombinable($option, $current, $combinations); + if ($combinable === null) { + $options->remove($option->getId()); + + continue; + } + $option->setGroup(null); + + $option->setCombinable($combinable); + } + + $group->setOptions($options); + } + + $product->addExtension('groups', $groups); + } + } + + public function loadCombinations(array $productIds, Context $context): array + { + $allCombinations = []; + + $query = $this->connection->createQueryBuilder(); + $query->from('product'); + $query->leftJoin('product', 'product', 'parent', 'product.parent_id = parent.id'); + + $query->andWhere('product.parent_id IN (:id)'); + $query->andWhere('product.version_id = :versionId'); + $query->andWhere('IFNULL(product.active, parent.active) = :active'); + $query->andWhere('product.option_ids IS NOT NULL'); + + $query->setParameter('id', Uuid::fromHexToBytesList($productIds), Connection::PARAM_STR_ARRAY); + $query->setParameter('versionId', Uuid::fromHexToBytes($context->getVersionId())); + $query->setParameter('active', true); + + $query->select([ + 'LOWER(HEX(product.id))', + 'LOWER(HEX(product.parent_id)) as parent_id', + 'product.option_ids as options', + 'product.product_number as productNumber', + 'product.available', + ]); + + $combinations = $query->execute()->fetchAll(); + $combinations = FetchModeHelper::groupUnique($combinations); + + foreach ($combinations as $combination) { + $parentId = $combination['parent_id']; + + if (\array_key_exists($parentId, $allCombinations)) { + $allCombinations[$parentId][] = $combination; + } else { + $allCombinations[$parentId] = [$combination]; + } + } + + foreach ($allCombinations as $parentId => $groupedCombinations) { + $available = []; + + foreach ($groupedCombinations as $combination) { + $combination['options'] = json_decode($combination['options'], true); + + $available[] = $combination; + } + + $result = new AvailableCombinationResult(); + + foreach ($available as $combination) { + $result->addCombination($combination['options']); + } + + $allCombinations[$parentId] = $result; + } + + return $allCombinations; + } + + private function loadSettings(ProductCollection $products, SalesChannelContext $context): ?array + { + $allSettings = []; + $criteria = (new Criteria())->addFilter( + new EqualsAnyFilter('productId', $products->map(function (SalesChannelProductEntity $product) { + return $product->getParentId() ?? $product->getId(); + })) + ); + + $criteria->addAssociation('option.group') + ->addAssociation('option.media') + ->addAssociation('media'); + + $settings = $this->configuratorRepository + ->search($criteria, $context->getContext()) + ->getEntities(); + + if ($settings->count() <= 0) { + return null; + } + + /** @var ProductConfiguratorSettingEntity $setting */ + foreach ($settings as $setting) { + $productId = $setting->getProductId(); + + if (\array_key_exists($productId, $allSettings)) { + $allSettings[$productId][] = clone $setting; + } else { + $allSettings[$productId] = [clone $setting]; + } + } + + /** @var ProductConfiguratorSettingEntity $setting */ + foreach ($allSettings as $productId => $settings) { + $groups = []; + + /** @var ProductConfiguratorSettingEntity $setting */ + foreach ($settings as $setting) { + $option = $setting->getOption(); + if ($option === null) { + continue; + } + + $group = $option->getGroup(); + if ($group === null) { + continue; + } + + $groupId = $group->getId(); + + // if (!in_array($groupId, $groupIds)) { + // continue; + // } + + if (isset($groups[$groupId])) { + $group = $groups[$groupId]; + } + + $groups[$groupId] = $group; + + if ($group->getOptions() === null) { + $group->setOptions(new PropertyGroupOptionCollection()); + } + + $group->getOptions()->add($option); + + $option->setConfiguratorSetting($setting); + } + + $allSettings[$productId] = $groups; + } + + return $allSettings; + } + + private function sortSettings(?array $groups, SalesChannelProductEntity $product): PropertyGroupCollection + { + if (!$groups) { + return new PropertyGroupCollection(); + } + + $sorted = []; + foreach ($groups as $group) { + if (!$group) { + continue; + } + + if (!$group->getOptions()) { + $group->setOptions(new PropertyGroupOptionCollection()); + } + + $sorted[$group->getId()] = $group; + } + + /** @var PropertyGroupEntity $group */ + foreach ($sorted as $group) { + $group->getOptions()->sort( + static function (PropertyGroupOptionEntity $a, PropertyGroupOptionEntity $b) use ($group) { + if ($a->getConfiguratorSetting()->getPosition() !== $b->getConfiguratorSetting()->getPosition()) { + return $a->getConfiguratorSetting()->getPosition() <=> $b->getConfiguratorSetting()->getPosition(); + } + + if ($group->getSortingType() === PropertyGroupDefinition::SORTING_TYPE_ALPHANUMERIC) { + return strnatcmp($a->getTranslation('name'), $b->getTranslation('name')); + } + + return ($a->getTranslation('position') ?? $a->getPosition() ?? 0) <=> ($b->getTranslation('position') ?? $b->getPosition() ?? 0); + } + ); + } + + $collection = new PropertyGroupCollection($sorted); + + // check if product has an individual sorting configuration for property groups + $config = $product->getConfiguratorGroupConfig(); + if (!$config) { + $collection->sortByPositions(); + + return $collection; + } + + $sortedGroupIds = array_column($config, 'id'); + + // ensure all ids are in the array (but only once) + $sortedGroupIds = array_unique(array_merge($sortedGroupIds, $collection->getIds())); + + $collection->sortByIdArray($sortedGroupIds); + + return $collection; + } + + private function isCombinable( + PropertyGroupOptionEntity $option, + array $current, + AvailableCombinationResult $combinations + ): ?bool { + unset($current[$option->getGroupId()]); + $current[] = $option->getId(); + + // available with all other current selected options + if ($combinations->hasCombination($current)) { + return true; + } + + // available but not with the other current selected options + if ($combinations->hasOptionId($option->getId())) { + return false; + } + + return null; + } + + private function buildCurrentOptions(SalesChannelProductEntity $product, PropertyGroupCollection $groups): array + { + $keyMap = $groups->getOptionIdMap(); + + $current = []; + foreach ($product->getOptionIds() as $optionId) { + $groupId = $keyMap[$optionId] ?? null; + if ($groupId === null) { + continue; + } + + $current[$groupId] = $optionId; + } + + return $current; + } +} diff --git a/src/Subscriber/CartPageLoadedSubscriber.php b/src/Subscriber/CartPageLoadedSubscriber.php new file mode 100644 index 0000000..465d8aa --- /dev/null +++ b/src/Subscriber/CartPageLoadedSubscriber.php @@ -0,0 +1,130 @@ +listingConfigurationLoader = $listingConfigurationLoader; + $this->productRepository = $productRepository; + $this->systemConfigService = $systemConfigService; + } + + public static function getSubscribedEvents() + { + return [ + OffcanvasCartPageLoadedEvent::class => [ + ['onOffCanvasCartPageLoaded', 201], + ], + CheckoutCartPageLoadedEvent::class => [ + ['onCheckoutCartPageLoaded', 201], + ], + CheckoutConfirmPageLoadedEvent::class => [ + ['onCheckoutConfirmPageLoaded', 201], + ] + ]; + } + + public function onOffCanvasCartPageLoaded(OffcanvasCartPageLoadedEvent $event): void + { + $context = $event->getSalesChannelContext(); + + if (!$this->systemConfigService->getBool(SasVariantSwitch::SHOW_ON_OFFCANVAS_CART, $context->getSalesChannelId())) { + return; + } + + $lineItems = $event->getPage()->getCart()->getLineItems()->filterType(LineItem::PRODUCT_LINE_ITEM_TYPE); + + if ($lineItems->count() === 0) { + return; + } + + $context = $event->getSalesChannelContext(); + + $this->addLineItemPropertyGroups($lineItems, $context); + } + + public function onCheckoutCartPageLoaded(CheckoutCartPageLoadedEvent $event): void + { + $context = $event->getSalesChannelContext(); + + if (!$this->systemConfigService->getBool(SasVariantSwitch::SHOW_ON_CART_PAGE, $context->getSalesChannelId())) { + return; + } + + $lineItems = $event->getPage()->getCart()->getLineItems()->filterType(LineItem::PRODUCT_LINE_ITEM_TYPE); + + if ($lineItems->count() === 0) { + return; + } + + $this->addLineItemPropertyGroups($lineItems, $context); + } + + public function onCheckoutConfirmPageLoaded(CheckoutConfirmPageLoadedEvent $event): void + { + $context = $event->getSalesChannelContext(); + + if (!$this->systemConfigService->getBool(SasVariantSwitch::SHOW_ON_CHECKOUT_CONFIRM_PAGE, $context->getSalesChannelId())) { + return; + } + + $lineItems = $event->getPage()->getCart()->getLineItems()->filterType(LineItem::PRODUCT_LINE_ITEM_TYPE); + + if ($lineItems->count() === 0) { + return; + } + + $this->addLineItemPropertyGroups($lineItems, $context); + } + + private function addLineItemPropertyGroups(LineItemCollection $lineItems, SalesChannelContext $context): void + { + $productIds = $lineItems->getReferenceIds(); + + $criteria = new Criteria($productIds); + + /** @var ProductCollection $products */ + $products = $this->productRepository->search($criteria, $context)->getEntities(); + + if ($products->count() <= 0) { + return; + } + + $this->listingConfigurationLoader->loadListing($products, $context); + + /** @var SalesChannelProductEntity $product */ + foreach ($products as $product) { + if ($product->getExtension('groups') !== null) { + $lineItem = $lineItems->get($product->getId()); + + $lineItem->addExtension('groups', $product->getExtension('groups')); + $lineItem->setPayloadValue('parentId', $product->getParentId()); + $lineItem->setPayloadValue('optionIds', $product->getOptionIds()); + } + } + } +} diff --git a/src/Subscriber/ProductListingResultLoadedSubscriber.php b/src/Subscriber/ProductListingResultLoadedSubscriber.php new file mode 100644 index 0000000..1bb3e31 --- /dev/null +++ b/src/Subscriber/ProductListingResultLoadedSubscriber.php @@ -0,0 +1,63 @@ +listingConfigurationLoader = $listingConfigurationLoader; + $this->systemConfigService = $systemConfigService; + } + + public static function getSubscribedEvents() + { + return [ + // 'sales_channel.product.loaded' => 'handleProductListingLoadedRequest', + ProductListingResultEvent::class => [ + ['handleProductListingLoadedRequest', 201], + ], + ProductBoxLoadedEvent::class => [ + ['handleProductBoxLoadedRequest', 201], + ], + ]; + } + + public function handleProductListingLoadedRequest(ProductListingResultEvent $event): void + { + $context = $event->getSalesChannelContext(); + + if (!$this->systemConfigService->getBool(SasVariantSwitch::SHOW_ON_PRODUCT_CARD, $context->getSalesChannelId())) { + return; + } + + /** @var ProductCollection $entities */ + $entities = $event->getResult()->getEntities(); + + $this->listingConfigurationLoader->loadListing($entities, $context); + } + + public function handleProductBoxLoadedRequest(ProductBoxLoadedEvent $event): void + { + $context = $event->getSalesChannelContext(); + + if (!$this->systemConfigService->getBool(SasVariantSwitch::SHOW_ON_PRODUCT_CARD, $context->getSalesChannelId())) { + return; + } + + $this->listingConfigurationLoader->loadListing(new ProductCollection([$event->getProduct()]), $context); + } +}