diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index be3d68da41..5f2594ca8b 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -22,3 +22,5 @@ - Added `\craft\commerce\services\Inventory::EVENT_AFTER_EXECUTE_INVENTORY_MOVEMENT`. ([#4063](https://github.com/craftcms/commerce/pull/4063)) ### System + +- Fixed a bug where purchasables could have a shipping category that was no longer available to their product type. ([#4018](https://github.com/craftcms/commerce/issues/4018)) diff --git a/src/controllers/ShippingCategoriesController.php b/src/controllers/ShippingCategoriesController.php index 0d27d8d158..00cee0963c 100644 --- a/src/controllers/ShippingCategoriesController.php +++ b/src/controllers/ShippingCategoriesController.php @@ -123,11 +123,16 @@ public function actionSave(): ?Response $shippingCategory->default = (bool)$this->request->getBodyParam('default'); // Set the new product types - $postedProductTypes = $this->request->getBodyParam('productTypes', []) ?: []; - $productTypes = []; - foreach ($postedProductTypes as $productTypeId) { - if ($productTypeId && $productType = Plugin::getInstance()->getProductTypes()->getProductTypeById($productTypeId)) { - $productTypes[] = $productType; + // If this is the default category, it should be available to all product types + if ($shippingCategory->default) { + $productTypes = Plugin::getInstance()->getProductTypes()->getAllProductTypes(); + } else { + $postedProductTypes = $this->request->getBodyParam('productTypes', []) ?: []; + $productTypes = []; + foreach ($postedProductTypes as $productTypeId) { + if ($productTypeId && $productType = Plugin::getInstance()->getProductTypes()->getProductTypeById($productTypeId)) { + $productTypes[] = $productType; + } } } $shippingCategory->setProductTypes($productTypes); diff --git a/src/services/ShippingCategories.php b/src/services/ShippingCategories.php index ff0282aaf4..4bf9481f0f 100644 --- a/src/services/ShippingCategories.php +++ b/src/services/ShippingCategories.php @@ -200,6 +200,42 @@ public function saveShippingCategory(ShippingCategory $shippingCategory, bool $r // Newly set product types this shipping category is available to $newProductTypeIds = ArrayHelper::getColumn($shippingCategory->getProductTypes(), 'id'); + // Find product types that are being removed from this shipping category + $removedProductTypeIds = array_diff($currentProductTypeIds, $newProductTypeIds); + + // Update purchasables to default shipping category when product types are removed + if (!empty($removedProductTypeIds)) { + $defaultShippingCategory = $this->getDefaultShippingCategory($shippingCategory->storeId); + + // Get all variant purchasables that currently have this shipping category but whose product type is being removed + $purchasableIds = (new Query()) + ->select(['ps.purchasableId']) + ->from(['ps' => Table::PURCHASABLES_STORES]) + ->innerJoin(['v' => Table::VARIANTS], '[[ps.purchasableId]] = [[v.id]]') + ->innerJoin(['p' => Table::PRODUCTS], '[[v.primaryOwnerId]] = [[p.id]]') + ->where([ + 'ps.shippingCategoryId' => $shippingCategory->id, + 'ps.storeId' => $shippingCategory->storeId, + 'p.typeId' => $removedProductTypeIds, + ]) + ->column(); + + if (!empty($purchasableIds)) { + // Update these purchasables to use the default shipping category + Craft::$app->getDb()->createCommand() + ->update( + Table::PURCHASABLES_STORES, + ['shippingCategoryId' => $defaultShippingCategory->id], + [ + 'purchasableId' => $purchasableIds, + 'storeId' => $shippingCategory->storeId, + 'shippingCategoryId' => $shippingCategory->id, + ] + ) + ->execute(); + } + } + foreach ($currentProductTypeIds as $oldProductTypeId) { // If we are removing a product type for this shipping category the products of that type should be re-saved if (!in_array($oldProductTypeId, $newProductTypeIds, false)) { diff --git a/src/templates/store-management/shipping/shippingcategories/_fields.twig b/src/templates/store-management/shipping/shippingcategories/_fields.twig index c0b6c8bce5..be92f16b7b 100644 --- a/src/templates/store-management/shipping/shippingcategories/_fields.twig +++ b/src/templates/store-management/shipping/shippingcategories/_fields.twig @@ -43,17 +43,27 @@ {% set warning = "" %} {% endif %} +{% set isDefault = shippingCategory is defined and shippingCategory.default %} +{% set productTypeValues = isDefault ? productTypesOptions|keys : (shippingCategory is defined ? shippingCategory.productTypeIds : []) %} + {{ forms.checkboxSelectField({ label: "Available to Product Types"|t('commerce'), - instructions: "Which product types should this category be available to?"|t('commerce'), + instructions: isDefault ? "The default shipping category is automatically available to all product types."|t('commerce') : "Which product types should this category be available to?"|t('commerce'), warning: warning, id: 'productTypes', name: 'productTypes', options: productTypesOptions, - values: shippingCategory is defined ? shippingCategory.productTypeIds : [], + values: productTypeValues, showAllOption: false, + disabled: isDefault, }) }} +{% if isDefault %} + {% for productTypeId in productTypesOptions|keys %} + {{ hiddenInput('productTypes[]', productTypeId) }} + {% endfor %} +{% endif %} + {% set defaultInput %} {{ forms.lightswitchField({ instructions: "This category will be used as the default for all purchasables in this store."|t('commerce'), @@ -80,3 +90,48 @@ new Craft.HandleGenerator('#{{ nameId }}', '#{{ handleId }}'); {% endjs %} {% endif %} + +{% set defaultSwitchId = 'default'|namespaceInputId|e('js') %} +{% set productTypesId = 'productTypes'|namespaceInputId|e('js') %} +{% js %} + (function() { + const defaultSwitch = document.getElementById('{{ defaultSwitchId }}'); + const productTypesContainer = document.getElementById('{{ productTypesId }}'); + + if (!defaultSwitch || !productTypesContainer) return; + + function updateProductTypes() { + const isDefault = defaultSwitch.classList.contains('on'); + const checkboxes = productTypesContainer.querySelectorAll('input[type="checkbox"]'); + + checkboxes.forEach(checkbox => { + if (isDefault) { + checkbox.checked = true; + checkbox.disabled = true; + } else { + checkbox.disabled = false; + } + }); + + // Update hidden inputs + const existingHiddenInputs = productTypesContainer.parentElement.querySelectorAll('input[type="hidden"][name="productTypes[]"]'); + existingHiddenInputs.forEach(input => input.remove()); + + if (isDefault) { + checkboxes.forEach(checkbox => { + const hiddenInput = document.createElement('input'); + hiddenInput.type = 'hidden'; + hiddenInput.name = 'productTypes[]'; + hiddenInput.value = checkbox.value; + productTypesContainer.parentElement.appendChild(hiddenInput); + }); + } + } + + // Listen for lightswitch changes + defaultSwitch.addEventListener('change', updateProductTypes); + + // Also listen for the custom lightswitch event + $(defaultSwitch).on('change', updateProductTypes); + })(); +{% endjs %} diff --git a/src/translations/en/commerce.php b/src/translations/en/commerce.php index 3d44c12fde..c160b1ae76 100644 --- a/src/translations/en/commerce.php +++ b/src/translations/en/commerce.php @@ -1116,7 +1116,8 @@ 'The base discount can only discount items in the cart to down to zero until it is used up, it can not make the order negative.' => 'The base discount can only discount items in the cart to down to zero until it is used up, it can not make the order negative.', 'The conversion rate that will be used when converting an amount to this currency. For example, if an item costs {amount1}, a conversion rate of {rate} would result in {amount2} in the alternate currency.' => 'The conversion rate that will be used when converting an amount to this currency. For example, if an item costs {amount1}, a conversion rate of {rate} would result in {amount2} in the alternate currency.', 'The countries that orders are allowed to be placed from.' => 'The countries that orders are allowed to be placed from.', - 'The email address that order status emails are sent from. Leave blank to use the System Email Address defined in Craft’s General Settings.' => 'The email address that order status emails are sent from. Leave blank to use the System Email Address defined in Craft’s General Settings.', + 'The default shipping category is automatically available to all product types.' => 'The default shipping category is automatically available to all product types.', + 'The email address that order status emails are sent from. Leave blank to use the System Email Address defined in Craft's General Settings.' => 'The email address that order status emails are sent from. Leave blank to use the System Email Address defined in Craft's General Settings.', 'The entry that contains the description for this subscription’s plan.' => 'The entry that contains the description for this subscription’s plan.', 'The flat value which should discount each item. i.e “3” for $3 off each item.' => 'The flat value which should discount each item. i.e “3” for $3 off each item.', 'The format used to generate new coupons, e.g. {example}. Any `#` characters will be replaced with a random letter.' => 'The format used to generate new coupons, e.g. {example}. Any `#` characters will be replaced with a random letter.',