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 |
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 |
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 |
--------------------------------------------------------------------------------