├── CHANGELOG_de-DE.md ├── CHANGELOG_en-GB.md ├── LICENSE.md ├── README.md ├── composer.json └── src ├── Resources ├── app │ └── storefront │ │ ├── dist │ │ └── storefront │ │ │ └── js │ │ │ └── sas-variant-switch.js │ │ └── src │ │ ├── main.js │ │ ├── plugin │ │ ├── offcanvas-cart-switch-option.plugin.js │ │ └── variant-hover-switch.plugin.js │ │ └── scss │ │ ├── _product-box.scss │ │ └── base.scss ├── config │ ├── config.xml │ ├── plugin.png │ ├── routes.xml │ └── services.xml └── views │ └── storefront │ ├── component │ ├── checkout │ │ ├── group │ │ │ └── box-card-group-select.html.twig │ │ └── offcanvas-item.html.twig │ └── product │ │ └── card │ │ ├── box-standard.html.twig │ │ └── group │ │ ├── box-card-group-input.html.twig │ │ └── box-card-group-select.html.twig │ ├── layout │ ├── meta.html.twig │ └── variant-switch-config.html.twig │ └── page │ └── checkout │ ├── checkout-item.html.twig │ └── confirm │ └── confirm-item.html.twig ├── SasVariantSwitch.php ├── Storefront ├── Controller │ └── VariantSwitchController.php ├── Event │ └── ProductBoxLoadedEvent.php └── Page │ └── ProductListingConfigurationLoader.php └── Subscriber ├── CartPageLoadedSubscriber.php └── ProductListingResultLoadedSubscriber.php /CHANGELOG_de-DE.md: -------------------------------------------------------------------------------- 1 | # 1.1.1 - Bugfix 2 | 3 | - [Issue #9](https://github.com/Shape-and-Shift/shopware-variant-switch/issues/9)] Positionen ohne valide Line-Items werden übersprungen 4 | 5 | # 1.1.0 - Erweiterung 6 | - [Issue #1](https://github.com/Shape-and-Shift/shopware-variant-switch/issues/1) Zeige auch Varianten, die nicht auf Lager sind 7 | - [Issue #2](https://github.com/Shape-and-Shift/shopware-variant-switch/issues/2) Ausblenden von geteilten Varianten 8 | - [Issue #6](https://github.com/Shape-and-Shift/shopware-variant-switch/issues/6) Ignoriert, dass das Produkt nicht kombinierbar ist 9 | 10 | # 1.0.0 - Erste Veröffentlichung 11 | - Variantenschalter auf der Produktliste anzeigen 12 | - Variantenschalter beim Hovern über eine Produkteigenschaft auf der Produktliste 13 | - Variantenschalter im Off-Canvas-Warenkorb anzeigen 14 | - Variantenschalter auf der Warenkorbseite anzeigen 15 | - Variantenschalter auf der Bestätigungsseite der Kaufabwicklung anzeigen 16 | - Alle diese Optionen sind konfigurierbar 17 | -------------------------------------------------------------------------------- /CHANGELOG_en-GB.md: -------------------------------------------------------------------------------- 1 | # 1.1.1 - Bugfix 2 | 3 | - [Issue #9](https://github.com/Shape-and-Shift/shopware-variant-switch/issues/9)] Carts-items without valid line items will be skipped 4 | 5 | # 1.1.0 - Enhancement 6 | - [Issue #1](https://github.com/Shape-and-Shift/shopware-variant-switch/issues/1) Show also variants which are out of stock 7 | - [Issue #2](https://github.com/Shape-and-Shift/shopware-variant-switch/issues/2) Hide split variants 8 | - [Issue #6](https://github.com/Shape-and-Shift/shopware-variant-switch/issues/6) Ignored the product don't have combinable 9 | 10 | # 1.0.0 - First release 11 | - Show variant switch on product listing card 12 | - Variant switch when hovering a variant property on product listing 13 | - Show variant switch on off-canvas cart 14 | - Show variant switch on cart page 15 | - Show variant switch on checkout confirm page 16 | - All these options are configurable 17 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Shape & Shift 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in 13 | > all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Variant switch for Shopware 6 2 | 3 | ## A plugin for [Shopware 6](https://github.com/shopware/platform) 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | ## Features 14 | - Show variant switch on product listing card 15 | - Variant switch when hovering a variant property on product listing 16 | - Show variant switch on off-canvas cart 17 | - Show variant switch on cart page 18 | - Show variant switch on checkout confirm page 19 | - All these options are configurable 20 | 21 | ## Requirements 22 | 23 | | Version | Requirements | 24 | |------------|---------------------------- | 25 | | 1.1.0 | Shopware 6.4 >= | 26 | 27 | ## License 28 | 29 | Plugin's Icon by [flaticon](https://www.flaticon.com). 30 | 31 | The plugin is released under MIT. For a full overview check the [LICENSE](./LICENSE) file. 32 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name":"sas/variant-switch", 3 | "description":"Show a variant switch within the listing and cart", 4 | "type":"shopware-platform-plugin", 5 | "license":"MIT", 6 | "version":"1.1.1", 7 | "keywords":[ 8 | "variant", 9 | "shopware" 10 | ], 11 | "authors":[ 12 | { 13 | "name":"Shape & Shift", 14 | "role":"Manufacturer", 15 | "homepage":"https://shapeandshift.dev" 16 | } 17 | ], 18 | "autoload":{ 19 | "psr-4":{ 20 | "SasVariantSwitch\\":"src/" 21 | } 22 | }, 23 | "require":{ 24 | "shopware/core":"^6.4", 25 | "shopware/storefront":"^6.4" 26 | }, 27 | "extra":{ 28 | "shopware-plugin-class":"SasVariantSwitch\\SasVariantSwitch", 29 | "plugin-icon":"src/Resources/config/plugin.png", 30 | "copyright":"(c) by Shape & Shift", 31 | "description":{ 32 | "de-DE":"Schnellwechsel der Produktvariante im Listing als auch im Warenkorb", 33 | "en-GB":"Show a variant switch within the listing and cart" 34 | }, 35 | "label":{ 36 | "de-DE":"Varianten switch im Listing", 37 | "en-GB":"Variant switch within the Listing" 38 | }, 39 | "manufacturerLink":{ 40 | "en-GB":"https://shapeandshift.dev", 41 | "de-DE":"https://shapeandshift.dev" 42 | }, 43 | "supportLink":{ 44 | "de-DE":"https://shapeandshift.dev", 45 | "en-GB":"https://shapeandshift.dev" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Resources/app/storefront/dist/storefront/js/sas-variant-switch.js: -------------------------------------------------------------------------------- 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))); 22 | } 23 | } 24 | 25 | _onSwitchLineItemOption(event) { 26 | const select = event.target; 27 | const form = select.closest('form'); 28 | const switchedInput = form.querySelector('.form-switched'); 29 | switchedInput.value = event.target.id; 30 | 31 | const selector = this.options.cartItemSelector; 32 | 33 | this.$emitter.publish('onSwitchLineItemOption'); 34 | 35 | this._fireRequest(form, selector); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Resources/app/storefront/src/plugin/variant-hover-switch.plugin.js: -------------------------------------------------------------------------------- 1 | import Plugin from 'src/plugin-system/plugin.class'; 2 | import HttpClient from 'src/service/http-client.service'; 3 | import ElementLoadingIndicatorUtil from 'src/utility/loading-indicator/element-loading-indicator.util'; 4 | import DomAccess from 'src/helper/dom-access.helper'; 5 | import Iterator from 'src/helper/iterator.helper'; 6 | import queryString from 'query-string'; 7 | 8 | export default class VariantHoverSwitchPlugin extends Plugin { 9 | static options = { 10 | radioFieldSelector: '.sas-product-configurator-option-input', 11 | selectFieldSelector: '.sas-product-configurator-select-input', 12 | urlAttribute: 'data-url', 13 | cardType: 'standard' 14 | }; 15 | 16 | init() { 17 | this._httpClient = new HttpClient(); 18 | this._radioFields = DomAccess.querySelectorAll(this.el, this.options.radioFieldSelector, false); 19 | this._selectFields = DomAccess.querySelectorAll(this.el, this.options.selectFieldSelector, false); 20 | 21 | this._productBox = this.el.closest('.product-box'); 22 | window.variantResponseCached = window.variantResponseCached || {}; 23 | this._hoveringValue = null; 24 | 25 | this._preserveCurrentValues(); 26 | this._registerEvents(); 27 | } 28 | 29 | /** 30 | * saves the current value on each form element 31 | * to be able to retrieve it once it has changed 32 | * 33 | * @private 34 | */ 35 | _preserveCurrentValues() { 36 | if(this._radioFields) { 37 | Iterator.iterate(this._radioFields, field => { 38 | if (VariantHoverSwitchPlugin._isFieldSerializable(field)) { 39 | if (field.dataset) { 40 | field.dataset.variantSwitchValue = field.value; 41 | } 42 | } 43 | }); 44 | } 45 | } 46 | 47 | /** 48 | * register all needed events 49 | * 50 | * @private 51 | */ 52 | _registerEvents() { 53 | if(this._radioFields) { 54 | Iterator.iterate(this._radioFields, field => { 55 | field.addEventListener('change', event => this._onChange(event.target)); 56 | const label = field.parentElement.querySelector('label'); 57 | 58 | if (window.sasPreviewVariantOnHover) { 59 | label.addEventListener('mouseenter', event => { 60 | const input = event.target.parentElement.querySelector('input'); 61 | this._hoveringValue = input.value; 62 | 63 | if (input && !input.checked) { 64 | setTimeout(() => { 65 | if (this._hoveringValue && this._hoveringValue === input.value) { 66 | input.click(); 67 | } 68 | }, 200) 69 | } 70 | }); 71 | 72 | label.addEventListener('mouseleave', event => { 73 | this._hoveringValue = null; 74 | }); 75 | } 76 | }); 77 | } 78 | 79 | if(this._selectFields) { 80 | Iterator.iterate(this._selectFields, field => { 81 | field.addEventListener('change', event => this._onChange(event.target)); 82 | }); 83 | } 84 | } 85 | 86 | /** 87 | * callback when the form has changed 88 | * 89 | * @param element 90 | * @private 91 | */ 92 | _onChange(element) { 93 | const switchedOptionId = this._getSwitchedOptionId(element); 94 | const selectedOptions = this._getFormValue(); 95 | this._preserveCurrentValues(); 96 | 97 | this.$emitter.publish('onChange'); 98 | 99 | const query = { 100 | switched: switchedOptionId, 101 | options: JSON.stringify(selectedOptions), 102 | cardType: this.options.cardType 103 | }; 104 | 105 | ElementLoadingIndicatorUtil.create(this.el); 106 | 107 | let url = DomAccess.getAttribute(element, this.options.urlAttribute); 108 | 109 | url = url + '?' + queryString.stringify({ ...query }); 110 | 111 | if (window.variantResponseCached[url]) { 112 | if (this._productBox) { 113 | this._productBox.outerHTML = window.variantResponseCached[url]; 114 | } 115 | 116 | ElementLoadingIndicatorUtil.remove(this.el); 117 | 118 | window.PluginManager.initializePlugins(); 119 | 120 | return; 121 | } 122 | 123 | this._httpClient.get(url, (response) => { 124 | window.variantResponseCached[url] = response; 125 | if (this._productBox) { 126 | this._productBox.outerHTML = response; 127 | } 128 | ElementLoadingIndicatorUtil.remove(this.el); 129 | 130 | window.PluginManager.initializePlugins() 131 | }); 132 | } 133 | 134 | /** 135 | * returns the option id of the recently switched field 136 | * 137 | * @param field 138 | * @returns {*} 139 | * @private 140 | */ 141 | _getSwitchedOptionId(field) { 142 | if (!VariantHoverSwitchPlugin._isFieldSerializable(field)) { 143 | return false; 144 | } 145 | 146 | return DomAccess.getAttribute(field, 'data-name'); 147 | } 148 | 149 | /** 150 | * returns the current selected 151 | * variant options from the form 152 | * 153 | * @private 154 | */ 155 | _getFormValue() { 156 | const serialized = {}; 157 | if(this._radioFields) { 158 | Iterator.iterate(this._radioFields, field => { 159 | if (VariantHoverSwitchPlugin._isFieldSerializable(field)) { 160 | if (field.checked) { 161 | serialized[DomAccess.getAttribute(field, 'data-name')] = field.value; 162 | } 163 | } 164 | }); 165 | } 166 | 167 | if(this._selectFields) { 168 | Iterator.iterate(this._selectFields, field => { 169 | if (VariantHoverSwitchPlugin._isFieldSerializable(field)) { 170 | const selectedOption = [...field.options].find(option => option.selected); 171 | serialized[DomAccess.getAttribute(field, 'data-name')] = selectedOption.value; 172 | } 173 | }); 174 | } 175 | 176 | return serialized; 177 | } 178 | 179 | /** 180 | * checks id the field is a value field 181 | * and therefore serializable 182 | * 183 | * @param field 184 | * @returns {boolean|*} 185 | * 186 | * @private 187 | */ 188 | static _isFieldSerializable(field) { 189 | return !field.name || field.disabled || ['file', 'reset', 'submit', 'button'].indexOf(field.type) === -1; 190 | } 191 | 192 | /** 193 | * disables all form fields on the form submit 194 | * 195 | * @private 196 | */ 197 | _disableFields() { 198 | Iterator.iterate(this._radioFields, field => { 199 | if (field.classList) { 200 | field.classList.add('disabled', 'disabled'); 201 | } 202 | }); 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/Resources/app/storefront/src/scss/_product-box.scss: -------------------------------------------------------------------------------- 1 | .product-box { 2 | .card-body { 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | } 7 | 8 | .sas-product-configurator-offcanvas-wrapper { 9 | margin-bottom: $spacer-md; 10 | border-bottom: 1px solid $border-color; 11 | } 12 | 13 | .sas-product-configurator-group { 14 | margin-bottom: $spacer-md; 15 | display: inline-block; 16 | margin-right: 5px; 17 | width: 100%; 18 | 19 | .custom-select { 20 | width: auto; 21 | } 22 | 23 | &-d-none { 24 | display: none!important; 25 | } 26 | } 27 | 28 | .sas-product-configurator-group-title { 29 | font-weight: $font-weight-bold; 30 | margin-bottom: $spacer-sm; 31 | display: block; 32 | } 33 | 34 | .sas-product-configurator-options { 35 | display: flex; 36 | flex-wrap: wrap; 37 | flex-direction: row; 38 | } 39 | 40 | .sas-product-configurator-option { 41 | display: inline-flex; 42 | margin-right: $spacer-sm; 43 | } 44 | 45 | .sas-product-configurator-option-input { 46 | display: none; 47 | 48 | + .sas-product-configurator-option-label { 49 | align-items: center; 50 | cursor: pointer; 51 | border: 1px dashed $dark; 52 | border-radius: $border-radius; 53 | box-shadow: inset 3px 3px 0 $white, inset -3px -3px 0 $white; 54 | background-color: $white; 55 | display: inline-flex; 56 | justify-content: center; 57 | height: 60px; 58 | min-width: 60px; 59 | opacity: 0.35; 60 | padding: 3px; 61 | 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); 62 | 63 | &.is-display-text { 64 | box-shadow: none; 65 | height: auto; 66 | padding: 5px 10px; 67 | } 68 | } 69 | 70 | &.is-combinable + .sas-product-configurator-option-label { 71 | opacity: 1; 72 | border: 1px solid $border-color; 73 | } 74 | 75 | &:checked + .sas-product-configurator-option-label { 76 | border: 1px solid $primary; 77 | 78 | &.is-display-text { 79 | background-color: $primary; 80 | color: $white; 81 | } 82 | } 83 | 84 | &.is-combinable + .sas-product-configurator-option-label, 85 | + .sas-product-configurator-option-label { 86 | &:hover, 87 | &:active, 88 | &:focus { 89 | border: 1px solid $primary; 90 | } 91 | } 92 | } 93 | 94 | .sas-product-configurator-option-image { 95 | height: 100%; 96 | } 97 | -------------------------------------------------------------------------------- /src/Resources/app/storefront/src/scss/base.scss: -------------------------------------------------------------------------------- 1 | @import './_product-box.scss'; 2 | -------------------------------------------------------------------------------- /src/Resources/config/config.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | Variant Switch Configuration 6 | Variante Schalterkonfiguration 7 | 8 | 9 | showOnProductCard 10 | 11 | 12 | true 13 | 14 | 15 | 16 | previewVariantOnHover 17 | 18 | 19 | true 20 | 21 | 22 | 23 | showOnOffCanvasCart 24 | 25 | 26 | true 27 | 28 | 29 | 30 | showOnCartPage 31 | 32 | 33 | true 34 | 35 | 36 | 37 | showOnCheckoutConfirmPage 38 | 39 | 40 | true 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/Resources/config/plugin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shape-and-Shift/shopware-variant-switch/63e5d5d4c6084538e1685c9cab80d0f88e57fa23/src/Resources/config/plugin.png -------------------------------------------------------------------------------- /src/Resources/config/routes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/Resources/config/services.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/Resources/views/storefront/component/checkout/group/box-card-group-select.html.twig: -------------------------------------------------------------------------------- 1 | {% block component_offcanvas_product_details_variant_switch_configurator_group_select %} 2 | {% block component_offcanvas_product_details_variant_switch_configurator_group_select_title %} 3 | 8 | {% block component_offcanvas_product_details_variant_switch_configurator_select %} 9 | 25 | {% endblock %} 26 | {% endblock %} 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /src/Resources/views/storefront/component/checkout/offcanvas-item.html.twig: -------------------------------------------------------------------------------- 1 | {% sw_extends '@Storefront/storefront/component/checkout/offcanvas-item.html.twig' %} 2 | 3 | {% block cart_item_variant_characteristics %} 4 | {% if lineItem.type != 'product' or not config('SasVariantSwitch.config.showOnOffCanvasCart') %} 5 | {{ parent() }} 6 | {% endif %} 7 | {% endblock %} 8 | 9 | {% block component_offcanvas_product_details_features %} 10 | {{ parent() }} 11 | {% if lineItem.type == 'product' and config('SasVariantSwitch.config.showOnOffCanvasCart') %} 12 | {% block component_offcanvas_product_details_variant_switch %} 13 | {% if lineItem.extensions.groups %} 14 | {% set optionIds = lineItem.payload.optionIds %} 15 | {% set parentId = lineItem.payload.parentId %} 16 | 17 |
18 |
19 | 20 | {% block component_offcanvas_product_details_variant_switch_form_csrf %} 21 | {{ sw_csrf('sas.frontend.lineItem.variant.switch') }} 22 | {% endblock %} 23 | 24 | {% block component_offcanvas_product_details_variant_switch_form_redirect %} 25 | 28 | {% endblock %} 29 | 30 | {% block component_offcanvas_product_details_variant_switch_parent_id %} 31 | 34 | {% endblock %} 35 | 36 | {% block component_offcanvas_product_details_variant_switch_switched %} 37 | 40 | {% endblock %} 41 | 42 | {% for group in lineItem.extensions.groups %} 43 | {% set groupIdentifier = [lineItem.referencedId, group.id]|join('-') %} 44 | 45 | {% block component_offcanvas_product_details_variant_switch_configurator_group %} 46 |
47 | {% sw_include '@Storefront/storefront/component/checkout/group/box-card-group-select.html.twig' %} 48 |
49 | {% endblock %} 50 | {% endfor %} 51 |
52 |
53 | {% endif %} 54 | {% endblock %} 55 | {% endif %} 56 | {% endblock %} 57 | 58 | -------------------------------------------------------------------------------- /src/Resources/views/storefront/component/product/card/box-standard.html.twig: -------------------------------------------------------------------------------- 1 | {% sw_extends '@Storefront/storefront/component/product/card/box-standard.html.twig' %} 2 | 3 | {% block component_product_box_variant_characteristics %} 4 | {{ parent() }} 5 | 6 | {% if config('SasVariantSwitch.config.showOnProductCard') %} 7 | {% block page_product_detail_configurator_groups %} 8 | {% set variantHoverSwitchOptions = { 9 | cardType: 'standard' 10 | } %} 11 | 12 | {% block component_offcanvas_product_details_variant_switch %} 13 | {% if product.extensions.groups %} 14 |
15 | {% for group in product.extensions.groups %} 16 | {% set hideOnListing = group.hideOnListing is defined and group.hideOnListing == true %} 17 | 18 | {% set groupIdentifier = [product.id, group.id]|join('-') %} 19 | {% block page_product_detail_configurator_group %} 20 |
21 | {% if group.displayType == 'select' %} 22 | {% sw_include '@Storefront/storefront/component/product/card/group/box-card-group-select.html.twig' %} 23 | {% else %} 24 | {% sw_include '@Storefront/storefront/component/product/card/group/box-card-group-input.html.twig' %} 25 | {% endif %} 26 |
27 | {% endblock %} 28 | {% endfor %} 29 |
30 | {% endif %} 31 | {% endblock %} 32 | {% endblock %} 33 | {% endif %} 34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /src/Resources/views/storefront/component/product/card/group/box-card-group-input.html.twig: -------------------------------------------------------------------------------- 1 | {% block component_product_box_configurator_group_select %} 2 | {% block component_product_box_configurator_group_title %} 3 |
4 | {% block component_product_box_configurator_group_select_title_text %} 5 | {{ group.translated.name }} 6 | {% endblock %} 7 |
8 | {% endblock %} 9 | 10 | {% block component_product_box_configurator_options %} 11 |
12 | {% for option in group.options %} 13 | 14 | {% set optionIdentifier = [groupIdentifier, option.id]|join('-') %} 15 | {% set isActive = false %} 16 | {% set isCombinableCls = 'is-combinable' %} 17 | 18 | {% if option.id in product.optionIds %} 19 | {% set isActive = true %} 20 | {% endif %} 21 | 22 | {% if not option.combinable %} 23 | {% set isCombinableCls = false %} 24 | {% endif %} 25 | 26 | {% if option.configuratorSetting.media %} 27 | {% set displayType = 'media' %} 28 | {% set media = option.configuratorSetting.media %} 29 | {% else %} 30 | {% set displayType = group.displayType %} 31 | {% if option.media %} 32 | {% set media = option.media %} 33 | {% else %} 34 | {% set media = false %} 35 | {% endif %} 36 | {% endif %} 37 | 38 | {% block component_product_box_configurator_option %} 39 |
40 | {% block component_product_box_configurator_option_radio %} 41 | 50 | 51 | {% block component_product_box_configurator_option_radio_label %} 52 | 81 | {% endblock %} 82 | {% endblock %} 83 |
84 | {% endblock %} 85 | {% endfor %} 86 |
87 | {% endblock %} 88 | {% endblock %} 89 | -------------------------------------------------------------------------------- /src/Resources/views/storefront/component/product/card/group/box-card-group-select.html.twig: -------------------------------------------------------------------------------- 1 | {% block component_product_box_configurator_group_select %} 2 | {% block component_product_box_configurator_group_select_title %} 3 | 8 | {% block component_product_box_configurator_select %} 9 | 25 | {% endblock %} 26 | {% endblock %} 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /src/Resources/views/storefront/layout/meta.html.twig: -------------------------------------------------------------------------------- 1 | {% sw_extends '@Storefront/storefront/layout/meta.html.twig' %} 2 | 3 | {% block layout_head_javascript_feature %} 4 | {{ parent() }} 5 | 6 | {% block sas_layout_head_javascript_variant_switch %} 7 | {% sw_include '@Storefront/storefront/layout/variant-switch-config.html.twig' %} 8 | {% endblock %} 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /src/Resources/views/storefront/layout/variant-switch-config.html.twig: -------------------------------------------------------------------------------- 1 | 22 | -------------------------------------------------------------------------------- /src/Resources/views/storefront/page/checkout/checkout-item.html.twig: -------------------------------------------------------------------------------- 1 | {% sw_extends '@Storefront/storefront/page/checkout/checkout-item.html.twig' %} 2 | 3 | {% block page_checkout_item_info_variant_characteristics %} 4 | {% if lineItem.type != 'product' or not config('SasVariantSwitch.config.showOnCartPage') %} 5 | {{ parent() }} 6 | {% endif %} 7 | {% endblock %} 8 | 9 | {% block page_checkout_item_info_features %} 10 | {{ parent() }} 11 | 12 | {% block page_checkout_item_info_features_variant_switch_container %} 13 | 14 | {% if lineItem.type == 'product' and config('SasVariantSwitch.config.showOnCartPage') %} 15 | 16 | {% block page_checkout_item_info_features_variant_switch %} 17 | {% if lineItem.extensions.groups %} 18 | {% set optionIds = lineItem.payload.optionIds %} 19 | {% set parentId = lineItem.payload.parentId %} 20 | 21 |
22 |
23 | 24 | {% block page_checkout_item_info_features_variant_switch_form_csrf %} 25 | {{ sw_csrf('sas.frontend.lineItem.variant.switch') }} 26 | {% endblock %} 27 | 28 | {% block page_checkout_item_quantity_redirect %} 29 | 32 | {% endblock %} 33 | 34 | {% block page_checkout_item_info_features_variant_switch_parent_id %} 35 | 38 | {% endblock %} 39 | 40 | {% block page_checkout_item_info_features_variant_switch_switched %} 41 | 45 | {% endblock %} 46 | 47 | {% for group in lineItem.extensions.groups %} 48 | {% set groupIdentifier = [lineItem.referencedId, group.id]|join('-') %} 49 | 50 | {% block page_checkout_item_info_features_variant_switch_configurator_group %} 51 |
52 | {% sw_include '@Storefront/storefront/component/checkout/group/box-card-group-select.html.twig' %} 53 |
54 | {% endblock %} 55 | {% endfor %} 56 |
57 |
58 | {% endif %} 59 | {% endblock %} 60 | 61 | {% endif %} 62 | {% endblock %} 63 | {% endblock %} 64 | -------------------------------------------------------------------------------- /src/Resources/views/storefront/page/checkout/confirm/confirm-item.html.twig: -------------------------------------------------------------------------------- 1 | {% sw_extends '@Storefront/storefront/page/checkout/confirm/confirm-item.html.twig' %} 2 | 3 | {% block page_checkout_item_info_variant_characteristics %} 4 | {% if lineItem.type != 'product' or not config('SasVariantSwitch.config.showOnCheckoutConfirmPage') %} 5 |
6 | 7 | {% for option in lineItem.payload.options %} 8 | {{ option.group }}: 9 | {{ option.option }} 10 | 11 | {% if lineItem.payload.options|last != option %} 12 | {{ " | " }} 13 | {% endif %} 14 | {% endfor %} 15 | 16 |
17 | {% endif %} 18 | {% endblock %} 19 | 20 | {% block page_checkout_item_info_features_variant_switch_container %} 21 | {% if lineItem.type == 'product' and config('SasVariantSwitch.config.showOnCheckoutConfirmPage') %} 22 | 23 | {% block page_checkout_item_info_features_variant_switch %} 24 | {{ parent() }} 25 | {% endblock %} 26 | 27 | {% endif %} 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /src/SasVariantSwitch.php: -------------------------------------------------------------------------------- 1 | combinationFinder = $combinationFinder; 46 | $this->productRepository = $productRepository; 47 | $this->cartService = $cartService; 48 | $this->dispatcher = $dispatcher; 49 | $this->lineItemFactory = $lineItemFactory; 50 | } 51 | 52 | /** 53 | * @HttpCache 54 | * @Route("/sas/line-item/switch-variant/{id}", name="sas.frontend.lineItem.variant.switch", methods={"POST"}, defaults={"XmlHttpRequest": true}) 55 | */ 56 | public function switchLineItemVariant(Cart $cart, string $id, Request $request, SalesChannelContext $context): Response 57 | { 58 | try { 59 | $options = $request->get('options'); 60 | 61 | if ($options === null) { 62 | throw new \InvalidArgumentException('options field is required'); 63 | } 64 | 65 | $productId = $request->get('parentId'); 66 | 67 | if ($productId === null) { 68 | throw new \InvalidArgumentException('parentId field is required'); 69 | } 70 | 71 | if (!$cart->has($id)) { 72 | throw new LineItemNotFoundException($id); 73 | } 74 | 75 | $lineItem = $cart->get($id); 76 | 77 | if ($lineItem->getType() !== LineItem::PRODUCT_LINE_ITEM_TYPE) { 78 | throw new \InvalidArgumentException('Line item is not a product'); 79 | } 80 | 81 | $switchedOption = $request->query->has('switched') ? (string) $request->query->get('switched') : null; 82 | 83 | try { 84 | $redirect = $this->combinationFinder->find($productId, $switchedOption, $options, $context); 85 | 86 | $productId = $redirect->getVariantId(); 87 | } catch (ProductNotFoundException $productNotFoundException) { 88 | //nth 89 | 90 | return new Response(); 91 | } 92 | 93 | $lineItems = $cart->getLineItems(); 94 | $newLineItems = new LineItemCollection(); 95 | 96 | /** @var LineItem $lineItem */ 97 | foreach ($lineItems as $lineItem) { 98 | if ($lineItem->getId() === $id) { 99 | $item = [ 100 | 'id' => $productId, 101 | 'referencedId' => $productId, 102 | 'stackable' => $lineItem->isStackable(), 103 | 'removable' => $lineItem->isRemovable(), 104 | 'quantity' => $lineItem->getQuantity(), 105 | 'type' => LineItem::PRODUCT_LINE_ITEM_TYPE 106 | ]; 107 | 108 | $newLineItem = $this->lineItemFactory->create($item, $context); 109 | 110 | if ($newLineItems->has($productId)) { 111 | $newLineItem->setQuantity($lineItem->getQuantity() + $newLineItems->get($productId)->getQuantity()); 112 | } 113 | 114 | $newLineItems->set($productId, $newLineItem); 115 | continue; 116 | } 117 | 118 | $newLineItems->add($lineItem); 119 | } 120 | 121 | $cart->setLineItems($newLineItems); 122 | $cart = $this->cartService->recalculate($cart, $context); 123 | 124 | if (!$this->traceErrors($cart)) { 125 | $this->addFlash(self::SUCCESS, $this->trans('checkout.cartUpdateSuccess')); 126 | } 127 | } catch (\Exception $exception) { 128 | $this->addFlash(self::DANGER, $this->trans('error.message-default')); 129 | } 130 | 131 | return $this->createActionResponse($request); 132 | } 133 | 134 | /** 135 | * @HttpCache 136 | * @Route("/sas/switch-variant/{productId}", name="sas.frontend.variant.switch", methods={"GET"}, defaults={"XmlHttpRequest": true}) 137 | */ 138 | public function switchVariant(string $productId, Request $request, SalesChannelContext $context): Response 139 | { 140 | $switchedOption = $request->query->has('switched') ? (string) $request->query->get('switched') : null; 141 | 142 | $cardType = $request->query->has('cardType') ? (string) $request->query->get('cardType') : 'standard'; 143 | 144 | $options = (string) $request->query->get('options'); 145 | $newOptions = $options !== '' ? json_decode($options, true) : []; 146 | 147 | try { 148 | $redirect = $this->combinationFinder->find($productId, $switchedOption, $newOptions, $context); 149 | 150 | $productId = $redirect->getVariantId(); 151 | } catch (ProductNotFoundException $productNotFoundException) { 152 | //nth 153 | 154 | return new Response(); 155 | } 156 | 157 | $criteria = (new Criteria([$productId])) 158 | ->addAssociation('manufacturer.media') 159 | ->addAssociation('options.group') 160 | ->addAssociation('properties.group') 161 | ->addAssociation('mainCategories.category') 162 | ->addAssociation('media'); 163 | 164 | $criteria->addExtension('sortings', new ProductSortingCollection()); 165 | 166 | $result = $this->productRepository->search($criteria, $context); 167 | 168 | $product = $result->get($productId); 169 | 170 | $this->dispatcher->dispatch( 171 | new ProductBoxLoadedEvent($request, $product, $context) 172 | ); 173 | 174 | return $this->renderStorefront("@Storefront/storefront/component/product/card/box-$cardType.html.twig", [ 175 | 'product' => $product, 176 | 'layout' => $cardType 177 | ]); 178 | } 179 | 180 | private function traceErrors(Cart $cart): bool 181 | { 182 | if ($cart->getErrors()->count() <= 0) { 183 | return false; 184 | } 185 | 186 | $this->addCartErrors($cart, function (Error $error) { 187 | return $error->isPersistent(); 188 | }); 189 | 190 | return true; 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/Storefront/Event/ProductBoxLoadedEvent.php: -------------------------------------------------------------------------------- 1 | request = $request; 21 | $this->context = $context; 22 | $this->product = $product; 23 | } 24 | 25 | public function getSalesChannelContext(): SalesChannelContext 26 | { 27 | return $this->context; 28 | } 29 | 30 | public function getContext(): Context 31 | { 32 | return $this->context->getContext(); 33 | } 34 | 35 | public function getProduct(): SalesChannelProductEntity 36 | { 37 | return $this->product; 38 | } 39 | 40 | public function getRequest(): Request 41 | { 42 | return $this->request; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Storefront/Page/ProductListingConfigurationLoader.php: -------------------------------------------------------------------------------- 1 | configuratorRepository = $configuratorRepository; 34 | $this->connection = $connection; 35 | } 36 | 37 | public function loadListing(ProductCollection $products, SalesChannelContext $context): void 38 | { 39 | $settings = $this->fetchSettings($products, $context->getContext()); 40 | 41 | $productIds = array_filter($products->map(function (SalesChannelProductEntity $product) { 42 | return $product->getParentId() ?? $product->getId(); 43 | })); 44 | 45 | $allCombinations = $this->loadCombinations($productIds, $context->getContext()); 46 | if (\count($allCombinations) === 0) { 47 | return; 48 | } 49 | 50 | /** @var SalesChannelProductEntity $product */ 51 | foreach ($products as $product) { 52 | $productSettings = $this->loadSettings(clone $settings); 53 | 54 | if ($product->getConfiguratorSettings() !== null || !$product->getParentId() || empty($productSettings[$product->getParentId()])) { 55 | $product->addExtension('groups', new PropertyGroupCollection()); 56 | 57 | continue; 58 | } 59 | 60 | $productSetting = $productSettings[$product->getParentId()]; 61 | 62 | $groups = $this->sortSettings($productSetting, $product); 63 | 64 | if (!array_key_exists($product->getParentId(), $allCombinations)) { 65 | continue; 66 | } 67 | 68 | $combinations = $allCombinations[$product->getParentId()]; 69 | 70 | $current = $this->buildCurrentOptions($product, $groups); 71 | 72 | foreach ($groups as $group) { 73 | $options = $group->getOptions(); 74 | if ($options === null) { 75 | continue; 76 | } 77 | 78 | foreach ($options as $option) { 79 | $combinable = $this->isCombinable($option, $current, $combinations); 80 | if ($combinable === null) { 81 | $options->remove($option->getId()); 82 | 83 | continue; 84 | } 85 | 86 | $option->setGroup(null); 87 | 88 | $option->setCombinable($combinable); 89 | } 90 | 91 | $group->setOptions($options); 92 | } 93 | 94 | $product->addExtension('groups', $groups); 95 | } 96 | } 97 | 98 | public function loadCombinations(array $productIds, Context $context): array 99 | { 100 | $allCombinations = []; 101 | 102 | $query = $this->connection->createQueryBuilder(); 103 | $query->from('product'); 104 | $query->leftJoin('product', 'product', 'parent', 'product.parent_id = parent.id'); 105 | 106 | $query->andWhere('product.parent_id IN (:id)'); 107 | $query->andWhere('product.version_id = :versionId'); 108 | $query->andWhere('IFNULL(product.active, parent.active) = :active'); 109 | $query->andWhere('product.option_ids IS NOT NULL'); 110 | 111 | $query->setParameter('id', Uuid::fromHexToBytesList($productIds), Connection::PARAM_STR_ARRAY); 112 | $query->setParameter('versionId', Uuid::fromHexToBytes($context->getVersionId())); 113 | $query->setParameter('active', true); 114 | 115 | $query->select([ 116 | 'LOWER(HEX(product.id))', 117 | 'LOWER(HEX(product.parent_id)) as parent_id', 118 | 'product.option_ids as options', 119 | 'product.product_number as productNumber', 120 | 'product.available', 121 | ]); 122 | 123 | $combinations = $query->execute()->fetchAll(); 124 | $combinations = FetchModeHelper::groupUnique($combinations); 125 | 126 | foreach ($combinations as $combination) { 127 | $parentId = $combination['parent_id']; 128 | 129 | if (\array_key_exists($parentId, $allCombinations)) { 130 | $allCombinations[$parentId][] = $combination; 131 | } else { 132 | $allCombinations[$parentId] = [$combination]; 133 | } 134 | } 135 | 136 | foreach ($allCombinations as $parentId => $groupedCombinations) { 137 | $result = new AvailableCombinationResult(); 138 | 139 | foreach ($groupedCombinations as $combination) { 140 | $available = (bool) $combination['available']; 141 | 142 | $options = json_decode($combination['options'], true); 143 | if ($options === false) { 144 | continue; 145 | } 146 | 147 | $result->addCombination($options, $available); 148 | } 149 | 150 | $allCombinations[$parentId] = $result; 151 | } 152 | 153 | return $allCombinations; 154 | } 155 | 156 | private function fetchSettings(ProductCollection $products, Context $context): ProductConfiguratorSettingCollection 157 | { 158 | $criteria = (new Criteria())->addFilter( 159 | new EqualsAnyFilter('productId', $products->map(function (SalesChannelProductEntity $product) { 160 | return $product->getParentId() ?? $product->getId(); 161 | })) 162 | ); 163 | 164 | $criteria->addAssociation('option.group') 165 | ->addAssociation('option.media') 166 | ->addAssociation('media'); 167 | 168 | /** 169 | * @var ProductConfiguratorSettingCollection $settings 170 | */ 171 | $settings = $this->configuratorRepository 172 | ->search($criteria, $context) 173 | ->getEntities(); 174 | 175 | if ($settings->count() <= 0) { 176 | return new ProductConfiguratorSettingCollection(); 177 | } 178 | 179 | return $settings; 180 | } 181 | 182 | private function loadSettings(ProductConfiguratorSettingCollection $settings): ?array 183 | { 184 | $allSettings = []; 185 | 186 | if ($settings->count() <= 0) { 187 | return null; 188 | } 189 | 190 | /** @var ProductConfiguratorSettingEntity $setting */ 191 | foreach ($settings as $setting) { 192 | $productId = $setting->getProductId(); 193 | 194 | if (\array_key_exists($productId, $allSettings)) { 195 | $allSettings[$productId][] = ProductConfiguratorSettingEntity::createFrom($setting); 196 | } else { 197 | $allSettings[$productId] = [ProductConfiguratorSettingEntity::createFrom($setting)]; 198 | } 199 | } 200 | 201 | foreach ($allSettings as $productId => $settings) { 202 | $groups = []; 203 | 204 | /** @var ProductConfiguratorSettingEntity $setting */ 205 | foreach ($settings as $setting) { 206 | $option = $setting->getOption(); 207 | if ($option === null) { 208 | continue; 209 | } 210 | 211 | $group = $option->getGroup(); 212 | if ($group === null) { 213 | continue; 214 | } 215 | 216 | $groupId = $group->getId(); 217 | 218 | // if (!in_array($groupId, $groupIds)) { 219 | // continue; 220 | // } 221 | 222 | if (isset($groups[$groupId])) { 223 | $group = $groups[$groupId]; 224 | } 225 | 226 | $groups[$groupId] = $group; 227 | 228 | if ($group->getOptions() === null) { 229 | $group->setOptions(new PropertyGroupOptionCollection()); 230 | } 231 | 232 | $group->getOptions()->add($option); 233 | 234 | $option->setConfiguratorSetting($setting); 235 | } 236 | 237 | $allSettings[$productId] = $groups; 238 | } 239 | 240 | return $allSettings; 241 | } 242 | 243 | private function sortSettings(?array $groups, SalesChannelProductEntity $product): PropertyGroupCollection 244 | { 245 | if (!$groups) { 246 | return new PropertyGroupCollection(); 247 | } 248 | 249 | $sorted = []; 250 | foreach ($groups as $group) { 251 | if (!$group) { 252 | continue; 253 | } 254 | 255 | if (!$group->getOptions()) { 256 | $group->setOptions(new PropertyGroupOptionCollection()); 257 | } 258 | 259 | $sorted[$group->getId()] = $group; 260 | } 261 | 262 | /** @var PropertyGroupEntity $group */ 263 | foreach ($sorted as $group) { 264 | $group->getOptions()->sort( 265 | static function (PropertyGroupOptionEntity $a, PropertyGroupOptionEntity $b) use ($group) { 266 | if ($a->getConfiguratorSetting()->getPosition() !== $b->getConfiguratorSetting()->getPosition()) { 267 | return $a->getConfiguratorSetting()->getPosition() <=> $b->getConfiguratorSetting()->getPosition(); 268 | } 269 | 270 | if ($group->getSortingType() === PropertyGroupDefinition::SORTING_TYPE_ALPHANUMERIC) { 271 | return strnatcmp($a->getTranslation('name'), $b->getTranslation('name')); 272 | } 273 | 274 | return ($a->getTranslation('position') ?? $a->getPosition() ?? 0) <=> ($b->getTranslation('position') ?? $b->getPosition() ?? 0); 275 | } 276 | ); 277 | } 278 | 279 | $collection = new PropertyGroupCollection($sorted); 280 | 281 | // check if product has an individual sorting configuration for property groups 282 | $config = $product->getConfiguratorGroupConfig(); 283 | if (!$config) { 284 | $collection->sortByPositions(); 285 | 286 | return $collection; 287 | } else if ($product->getMainVariantId() === null) { 288 | foreach ($config as $item) { 289 | if (\array_key_exists('expressionForListings', $item) && $item['expressionForListings'] && $collection->has($item['id'])) { 290 | $collection->get($item['id'])->assign([ 291 | 'hideOnListing' => true, 292 | ]); 293 | } 294 | } 295 | } 296 | 297 | $sortedGroupIds = array_column($config, 'id'); 298 | 299 | // ensure all ids are in the array (but only once) 300 | $sortedGroupIds = array_unique(array_merge($sortedGroupIds, $collection->getIds())); 301 | 302 | $collection->sortByIdArray($sortedGroupIds); 303 | 304 | return $collection; 305 | } 306 | 307 | private function isCombinable( 308 | PropertyGroupOptionEntity $option, 309 | array $current, 310 | AvailableCombinationResult $combinations 311 | ): ?bool { 312 | unset($current[$option->getGroupId()]); 313 | $current[] = $option->getId(); 314 | 315 | // available with all other current selected options 316 | if ($combinations->hasCombination($current) && $combinations->isAvailable($current)) { 317 | return true; 318 | } 319 | 320 | // available but not with the other current selected options 321 | if ($combinations->hasOptionId($option->getId())) { 322 | return false; 323 | } 324 | 325 | return null; 326 | } 327 | 328 | private function buildCurrentOptions(SalesChannelProductEntity $product, PropertyGroupCollection $groups): array 329 | { 330 | $keyMap = $groups->getOptionIdMap(); 331 | 332 | $current = []; 333 | foreach ($product->getOptionIds() as $optionId) { 334 | $groupId = $keyMap[$optionId] ?? null; 335 | if ($groupId === null) { 336 | continue; 337 | } 338 | 339 | $current[$groupId] = $optionId; 340 | } 341 | 342 | return $current; 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /src/Subscriber/CartPageLoadedSubscriber.php: -------------------------------------------------------------------------------- 1 | listingConfigurationLoader = $listingConfigurationLoader; 32 | $this->productRepository = $productRepository; 33 | $this->systemConfigService = $systemConfigService; 34 | } 35 | 36 | public static function getSubscribedEvents() 37 | { 38 | return [ 39 | OffcanvasCartPageLoadedEvent::class => [ 40 | ['onOffCanvasCartPageLoaded', 201], 41 | ], 42 | CheckoutCartPageLoadedEvent::class => [ 43 | ['onCheckoutCartPageLoaded', 201], 44 | ], 45 | CheckoutConfirmPageLoadedEvent::class => [ 46 | ['onCheckoutConfirmPageLoaded', 201], 47 | ] 48 | ]; 49 | } 50 | 51 | public function onOffCanvasCartPageLoaded(OffcanvasCartPageLoadedEvent $event): void 52 | { 53 | $context = $event->getSalesChannelContext(); 54 | 55 | if (!$this->systemConfigService->getBool(SasVariantSwitch::SHOW_ON_OFFCANVAS_CART, $context->getSalesChannelId())) { 56 | return; 57 | } 58 | 59 | $lineItems = $event->getPage()->getCart()->getLineItems()->filterType(LineItem::PRODUCT_LINE_ITEM_TYPE); 60 | 61 | if ($lineItems->count() === 0) { 62 | return; 63 | } 64 | 65 | $context = $event->getSalesChannelContext(); 66 | 67 | $this->addLineItemPropertyGroups($lineItems, $context); 68 | } 69 | 70 | public function onCheckoutCartPageLoaded(CheckoutCartPageLoadedEvent $event): void 71 | { 72 | $context = $event->getSalesChannelContext(); 73 | 74 | if (!$this->systemConfigService->getBool(SasVariantSwitch::SHOW_ON_CART_PAGE, $context->getSalesChannelId())) { 75 | return; 76 | } 77 | 78 | $lineItems = $event->getPage()->getCart()->getLineItems()->filterType(LineItem::PRODUCT_LINE_ITEM_TYPE); 79 | 80 | if ($lineItems->count() === 0) { 81 | return; 82 | } 83 | 84 | $this->addLineItemPropertyGroups($lineItems, $context); 85 | } 86 | 87 | public function onCheckoutConfirmPageLoaded(CheckoutConfirmPageLoadedEvent $event): void 88 | { 89 | $context = $event->getSalesChannelContext(); 90 | 91 | if (!$this->systemConfigService->getBool(SasVariantSwitch::SHOW_ON_CHECKOUT_CONFIRM_PAGE, $context->getSalesChannelId())) { 92 | return; 93 | } 94 | 95 | $lineItems = $event->getPage()->getCart()->getLineItems()->filterType(LineItem::PRODUCT_LINE_ITEM_TYPE); 96 | 97 | if ($lineItems->count() === 0) { 98 | return; 99 | } 100 | 101 | $this->addLineItemPropertyGroups($lineItems, $context); 102 | } 103 | 104 | private function addLineItemPropertyGroups(LineItemCollection $lineItems, SalesChannelContext $context): void 105 | { 106 | $productIds = $lineItems->getReferenceIds(); 107 | 108 | $criteria = new Criteria($productIds); 109 | 110 | /** @var ProductCollection $products */ 111 | $products = $this->productRepository->search($criteria, $context)->getEntities(); 112 | 113 | if ($products->count() <= 0) { 114 | return; 115 | } 116 | 117 | $this->listingConfigurationLoader->loadListing($products, $context); 118 | 119 | /** @var SalesChannelProductEntity $product */ 120 | foreach ($products as $product) { 121 | if ($product->getExtension('groups') !== null) { 122 | $lineItem = $lineItems->get($product->getId()); 123 | 124 | if (null !== $lineItem) { 125 | $lineItem->addExtension('groups', $product->getExtension('groups')); 126 | $lineItem->setPayloadValue('parentId', $product->getParentId()); 127 | $lineItem->setPayloadValue('optionIds', $product->getOptionIds()); 128 | } 129 | } 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/Subscriber/ProductListingResultLoadedSubscriber.php: -------------------------------------------------------------------------------- 1 | listingConfigurationLoader = $listingConfigurationLoader; 23 | $this->systemConfigService = $systemConfigService; 24 | } 25 | 26 | public static function getSubscribedEvents() 27 | { 28 | return [ 29 | // 'sales_channel.product.loaded' => 'handleProductListingLoadedRequest', 30 | ProductListingResultEvent::class => [ 31 | ['handleProductListingLoadedRequest', 201], 32 | ], 33 | ProductBoxLoadedEvent::class => [ 34 | ['handleProductBoxLoadedRequest', 201], 35 | ], 36 | ]; 37 | } 38 | 39 | public function handleProductListingLoadedRequest(ProductListingResultEvent $event): void 40 | { 41 | $context = $event->getSalesChannelContext(); 42 | 43 | if (!$this->systemConfigService->getBool(SasVariantSwitch::SHOW_ON_PRODUCT_CARD, $context->getSalesChannelId())) { 44 | return; 45 | } 46 | 47 | /** @var ProductCollection $entities */ 48 | $entities = $event->getResult()->getEntities(); 49 | 50 | $this->listingConfigurationLoader->loadListing($entities, $context); 51 | } 52 | 53 | public function handleProductBoxLoadedRequest(ProductBoxLoadedEvent $event): void 54 | { 55 | $context = $event->getSalesChannelContext(); 56 | 57 | if (!$this->systemConfigService->getBool(SasVariantSwitch::SHOW_ON_PRODUCT_CARD, $context->getSalesChannelId())) { 58 | return; 59 | } 60 | 61 | $this->listingConfigurationLoader->loadListing(new ProductCollection([$event->getProduct()]), $context); 62 | } 63 | } 64 | --------------------------------------------------------------------------------