diff --git a/routes/actions.php b/routes/actions.php index 79ce7048ea..791dde6804 100644 --- a/routes/actions.php +++ b/routes/actions.php @@ -5,35 +5,36 @@ use CraftCms\Commerce\Http\Controllers\CartController; use CraftCms\Commerce\Http\Controllers\DonationsController; use CraftCms\Commerce\Http\Controllers\OrdersController; -use CraftCms\Commerce\Http\Controllers\Settings\CatalogPricingController; -use CraftCms\Commerce\Http\Controllers\Settings\CatalogPricingRulesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\CatalogPricingController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\CatalogPricingRulesController; use CraftCms\Commerce\Http\Controllers\DownloadsController; use CraftCms\Commerce\Http\Controllers\EmailPreviewController; -use CraftCms\Commerce\Http\Controllers\Settings\DiscountsController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\DiscountsController; use CraftCms\Commerce\Http\Controllers\Settings\EmailsController; use CraftCms\Commerce\Http\Controllers\FormulasController; use CraftCms\Commerce\Http\Controllers\Settings\GatewaysController; +use CraftCms\Commerce\Http\Controllers\Settings\GeneralSettingsController; use CraftCms\Commerce\Http\Controllers\InventoryController; use CraftCms\Commerce\Http\Controllers\InventoryLocationsController; use CraftCms\Commerce\Http\Controllers\Settings\LineItemStatusesController; use CraftCms\Commerce\Http\Controllers\Settings\OrderSettingsController; use CraftCms\Commerce\Http\Controllers\Settings\OrderStatusesController; -use CraftCms\Commerce\Http\Controllers\Settings\PaymentCurrenciesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\PaymentCurrenciesController; use CraftCms\Commerce\Http\Controllers\PaymentSourcesController; use CraftCms\Commerce\Http\Controllers\PaymentsController; use CraftCms\Commerce\Http\Controllers\Settings\PdfsController; use CraftCms\Commerce\Http\Controllers\Settings\ProductTypesController; -use CraftCms\Commerce\Http\Controllers\Settings\SalesController; -use CraftCms\Commerce\Http\Controllers\Settings\SettingsController; -use CraftCms\Commerce\Http\Controllers\Settings\ShippingCategoriesController; -use CraftCms\Commerce\Http\Controllers\Settings\ShippingMethodsController; -use CraftCms\Commerce\Http\Controllers\Settings\ShippingRulesController; -use CraftCms\Commerce\Http\Controllers\Settings\ShippingZonesController; -use CraftCms\Commerce\Http\Controllers\Settings\StoreManagementController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\SalesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\ShippingCategoriesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\ShippingMethodsController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\ShippingRulesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\ShippingZonesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\StoreManagementController; use CraftCms\Commerce\Http\Controllers\Settings\StoresController; -use CraftCms\Commerce\Http\Controllers\Settings\TaxCategoriesController; -use CraftCms\Commerce\Http\Controllers\Settings\TaxRatesController; -use CraftCms\Commerce\Http\Controllers\Settings\TaxZonesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\TaxCategoriesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\TaxRatesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\TaxZonesController; +use CraftCms\Commerce\Http\Controllers\Settings\TransferSettingsController; use CraftCms\Commerce\Http\Controllers\TransfersController; use CraftCms\Commerce\Http\Controllers\UserOrdersController; use CraftCms\Commerce\Http\Controllers\WebhooksController; @@ -81,8 +82,8 @@ Route::post('gateways/archive', [GatewaysController::class, 'archive']); Route::post('gateways/reorder', [GatewaysController::class, 'reorder']); - Route::post('settings/save-settings', [SettingsController::class, 'saveSettings']); - Route::post('settings/save-transfer-settings', [SettingsController::class, 'saveTransferSettings']); + Route::post('settings/save-settings', [GeneralSettingsController::class, 'saveSettings']); + Route::post('settings/save-transfer-settings', [TransferSettingsController::class, 'saveTransferSettings']); Route::post('order-settings/save', [OrderSettingsController::class, 'save']); Route::post('stores/save-store', [StoresController::class, 'saveStore']); diff --git a/routes/cp.php b/routes/cp.php index 33c6375606..4130e826a0 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -3,32 +3,33 @@ use CraftCms\Cms\Http\Controllers\Elements\EditElementController; use CraftCms\Cms\Http\Middleware\RequireAdmin; use CraftCms\Commerce\Http\Controllers\DonationsController; -use CraftCms\Commerce\Http\Controllers\Settings\CatalogPricingController; -use CraftCms\Commerce\Http\Controllers\Settings\CatalogPricingRulesController; -use CraftCms\Commerce\Http\Controllers\Settings\DiscountsController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\CatalogPricingController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\CatalogPricingRulesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\DiscountsController; use CraftCms\Commerce\Http\Controllers\Settings\EmailsController; use CraftCms\Commerce\Http\Controllers\Settings\GatewaysController; use CraftCms\Commerce\Http\Controllers\InventoryController; use CraftCms\Commerce\Http\Controllers\InventoryLocationsController; use CraftCms\Commerce\Http\Controllers\OrdersController; +use CraftCms\Commerce\Http\Controllers\Settings\GeneralSettingsController; use CraftCms\Commerce\Http\Controllers\Settings\LineItemStatusesController; use CraftCms\Commerce\Http\Controllers\Settings\OrderSettingsController; use CraftCms\Commerce\Http\Controllers\Settings\OrderStatusesController; -use CraftCms\Commerce\Http\Controllers\Settings\PaymentCurrenciesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\PaymentCurrenciesController; use CraftCms\Commerce\Http\Controllers\Settings\PdfsController; use CraftCms\Commerce\Http\Controllers\ProductsController; use CraftCms\Commerce\Http\Controllers\Settings\ProductTypesController; -use CraftCms\Commerce\Http\Controllers\Settings\SalesController; -use CraftCms\Commerce\Http\Controllers\Settings\SettingsController; -use CraftCms\Commerce\Http\Controllers\Settings\ShippingCategoriesController; -use CraftCms\Commerce\Http\Controllers\Settings\ShippingMethodsController; -use CraftCms\Commerce\Http\Controllers\Settings\ShippingRulesController; -use CraftCms\Commerce\Http\Controllers\Settings\ShippingZonesController; -use CraftCms\Commerce\Http\Controllers\Settings\StoreManagementController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\SalesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\ShippingCategoriesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\ShippingMethodsController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\ShippingRulesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\ShippingZonesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\StoreManagementController; use CraftCms\Commerce\Http\Controllers\Settings\StoresController; -use CraftCms\Commerce\Http\Controllers\Settings\TaxCategoriesController; -use CraftCms\Commerce\Http\Controllers\Settings\TaxRatesController; -use CraftCms\Commerce\Http\Controllers\Settings\TaxZonesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\TaxCategoriesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\TaxRatesController; +use CraftCms\Commerce\Http\Controllers\StoreManagement\TaxZonesController; +use CraftCms\Commerce\Http\Controllers\Settings\TransferSettingsController; use CraftCms\Commerce\Http\Controllers\TransfersController; use CraftCms\Commerce\Http\Controllers\Users\UsersController; use CraftCms\Commerce\Http\Controllers\VariantsController; @@ -41,11 +42,12 @@ Route::middleware(RequireAdmin::class)->group(function () { Route::get('commerce/settings/gateways', [GatewaysController::class, 'index']); Route::get('commerce/settings/gateways/new', [GatewaysController::class, 'edit']); + Route::post('commerce/settings/gateways/render-form', [GatewaysController::class, 'renderForm']); Route::get('commerce/settings/gateways/{id}', [GatewaysController::class, 'edit'])->whereNumber('id'); - Route::get('commerce/settings/general', [SettingsController::class, 'edit']); + Route::get('commerce/settings/general', [GeneralSettingsController::class, 'edit']); Route::get('commerce/settings/ordersettings', [OrderSettingsController::class, 'edit']); - Route::get('commerce/settings/transfers', [SettingsController::class, 'editTransferSettings']); + Route::get('commerce/settings/transfers', [TransferSettingsController::class, 'editTransferSettings']); Route::get('commerce/settings/stores', [StoresController::class, 'storesIndex']); Route::get('commerce/settings/stores/new', [StoresController::class, 'editStore']); @@ -62,10 +64,12 @@ Route::get('commerce/settings/producttypes', [ProductTypesController::class, 'productTypeIndex']); Route::get('commerce/settings/producttypes/new', [ProductTypesController::class, 'editProductType']); + Route::post('commerce/settings/producttypes/render-form', [ProductTypesController::class, 'renderForm']); Route::get('commerce/settings/producttypes/{productTypeId}', [ProductTypesController::class, 'editProductType'])->whereNumber('productTypeId'); Route::get('commerce/settings/emails', [EmailsController::class, 'index']); Route::get('commerce/settings/emails/{storeHandle}/new', [EmailsController::class, 'edit']); + Route::post('commerce/settings/emails/render-form', [EmailsController::class, 'renderForm']); Route::get('commerce/settings/emails/{storeHandle}/{id}', [EmailsController::class, 'edit'])->whereNumber('id'); Route::get('commerce/settings/pdfs', [PdfsController::class, 'index']); diff --git a/src-yii2/templates/store-management/discounts/_edit.twig b/src-yii2/templates/store-management/discounts/_edit.twig index 226d2b619d..881e317ba4 100644 --- a/src-yii2/templates/store-management/discounts/_edit.twig +++ b/src-yii2/templates/store-management/discounts/_edit.twig @@ -236,45 +236,29 @@
{{ "Filtering conditions which describe to which orders this rule is applicable to. Write 0 to skip a condition."|t('commerce') }}
- {% set orderConditionInput %} - {{ shippingRule.orderCondition|commerceConditionBuilderHtml }} - {% endset %} - {{ forms.field({ id: 'orderCondition', label: 'Match Order'|t('commerce'), errors: shippingRule.getErrors('orderCondition') - }, orderConditionInput) }} + }, orderConditionHtml) }} {{ 'Advanced'|t('commerce') }} @@ -157,15 +153,11 @@ tags get the same rendered result.
+ 'ex1' => '`{number}`',
+ 'ex2' => '`{myOrderCustomField}`',
+ ], category: 'commerce'));
+ $formNodes[] = Field::make(t('Language', category: 'commerce'), Choice::make('language')->options($languageOptions))
+ ->instructions(t('The language to be used when PDF is rendered.'));
+ $formNodes[] = Field::make(t('Paper Orientation', category: 'commerce'), Choice::make('paperOrientation')->options($paperOrientationOptions));
+ $formNodes[] = Field::make(t('Paper Size', category: 'commerce'), Choice::make('paperSize')->options($paperSizeOptions));
+ $formNodes[] = Field::make(t('Link Duration', category: 'commerce'), Number::make('linkExpiry')->min(1))
+ ->instructions(t('How long (in seconds) a PDF download link should remain valid before expiring. Default is 86400 (24 hours).', category: 'commerce'));
+ $formNodes[] = Field::make(t('Enabled?', category: 'commerce'), Lightswitch::make('enabled'))
+ ->instructions(t('If disabled, this PDF will not be available or sent with emails.', category: 'commerce'));
+ $formNodes[] = $defaultNode;
+
+ $form = $this->formResolver->resolve(Form::make($formNodes), new FormContext(
+ values: [
+ 'storeId' => $store->id,
+ 'id' => $pdf->id,
+ 'name' => $pdf->name,
+ 'handle' => $pdf->handle,
+ 'description' => $pdf->description,
+ 'templatePath' => $pdf->templatePath,
+ 'fileNameFormat' => $pdf->fileNameFormat,
+ 'language' => $pdf->language,
+ 'paperOrientation' => $pdf->paperOrientation,
+ 'paperSize' => $pdf->paperSize,
+ 'linkExpiry' => $pdf->linkExpiry,
+ 'enabled' => $pdf->enabled,
+ 'isDefault' => $isDefault,
+ ],
+ mode: $this->generalConfig->allowAdminChanges ? ControlMode::Editable : ControlMode::ReadOnly,
+ ));
+
+ return $this->cpScreenResponse()
->title($title)
- ->crumbs([
- ['label' => t('Commerce', category: 'commerce'), 'url' => 'commerce'],
- ['label' => t('Settings'), 'url' => 'commerce/settings', 'ariaLabel' => t('Commerce Settings', category: 'commerce')],
- ['label' => t('PDFs', category: 'commerce'), 'url' => 'commerce/settings/pdfs'],
- ])
- ->selectedSubnavItem('settings')
+ ->crumbs($pdf->id ? $this->crumbs(['label' => $title]) : $this->crumbs())
->action('commerce/pdfs/save')
->redirectUrl('commerce/settings/pdfs')
- ->contentTemplate('commerce/settings/pdfs/_edit', [
- 'pdf' => $pdf,
- 'pdfLanguageOptions' => $pdfLanguageOptions,
- 'isDefault' => $isDefault,
- 'paperOrientationOptions' => $paperOrientationOptions,
- 'paperSizeOptions' => $paperSizeOptions,
- 'readOnly' => $this->readOnly,
+ ->inertiaPage('Form', [
+ 'form' => $form,
+ 'submit' => [
+ 'method' => 'post',
+ 'url' => action([self::class, 'save']),
+ ],
]);
}
diff --git a/src/Http/Controllers/Settings/ProductTypesController.php b/src/Http/Controllers/Settings/ProductTypesController.php
index e23a685043..90f1bbba4e 100644
--- a/src/Http/Controllers/Settings/ProductTypesController.php
+++ b/src/Http/Controllers/Settings/ProductTypesController.php
@@ -4,45 +4,98 @@
namespace CraftCms\Commerce\Http\Controllers\Settings;
-use craft\web\assets\editsection\EditSectionAsset;
-use CraftCms\Cms\Config\GeneralConfig;
+use CraftCms\Cms\Cp\SelectOptions;
use CraftCms\Cms\Element\Enums\PropagationMethod;
-use CraftCms\Cms\Http\RespondsWithFlash;
+use CraftCms\Cms\Field\Enums\TranslationMethod;
+use CraftCms\Cms\Form\Controls\Choice;
+use CraftCms\Cms\Form\Controls\FieldLayoutDesigner;
+use CraftCms\Cms\Form\Controls\Handle;
+use CraftCms\Cms\Form\Controls\Lightswitch;
+use CraftCms\Cms\Form\Controls\Number;
+use CraftCms\Cms\Form\Controls\Table as TableControl;
+use CraftCms\Cms\Form\Controls\Text;
+use CraftCms\Cms\Form\Enums\ControlMode;
+use CraftCms\Cms\Form\Form;
+use CraftCms\Cms\Form\FormContext;
+use CraftCms\Cms\Form\Nodes\Field;
+use CraftCms\Cms\Form\Nodes\Heading;
+use CraftCms\Cms\Form\Nodes\HiddenField;
+use CraftCms\Cms\Form\Nodes\Separator;
+use CraftCms\Cms\Form\Nodes\Table;
+use CraftCms\Cms\Form\Nodes\TemplateContent;
use CraftCms\Cms\Http\Responses\CpScreenResponse;
use CraftCms\Cms\Support\Facades\Fields;
use CraftCms\Cms\Support\Facades\Sites;
-use CraftCms\Cms\View\TemplateMode;
+use CraftCms\Cms\Support\Html;
use CraftCms\Commerce\Product\Elements\Product;
use CraftCms\Commerce\Product\ProductType\Data\ProductType;
use CraftCms\Commerce\Product\ProductType\Data\ProductTypeSite;
use CraftCms\Commerce\Product\ProductType\ProductTypes;
use CraftCms\Commerce\Product\Variant\Elements\Variant;
+use CraftCms\Commerce\Shipping\Data\ShippingCategory;
+use CraftCms\Commerce\Tax\Data\TaxCategory;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
+use function CraftCms\Cms\cp_url;
use function CraftCms\Cms\currentUser;
-use function CraftCms\Cms\pageTemplate;
use function CraftCms\Cms\t;
-readonly class ProductTypesController
+class ProductTypesController extends BaseSettingsController
{
- use RespondsWithFlash;
-
- private bool $readOnly;
-
- public function __construct(GeneralConfig $generalConfig)
+ protected function getSectionCrumb(): array
{
- $this->readOnly = !$generalConfig->allowAdminChanges;
+ return ['label' => t('Product Types', category: 'commerce'), 'href' => cp_url('commerce/settings/producttypes')];
}
- public function productTypeIndex(): string
+ public function productTypeIndex(): CpScreenResponse
{
- $productTypes = app(ProductTypes::class)->getAllProductTypes();
-
- return pageTemplate('commerce/settings/producttypes/index', [
- 'productTypes' => $productTypes,
- 'readOnly' => $this->readOnly,
- ], TemplateMode::Cp);
+ $canManageShipping = (bool)currentUser()?->can('commerce-manageShipping');
+ $canManageTaxes = (bool)currentUser()?->can('commerce-manageTaxes');
+
+ $rows = array_map(fn(ProductType $productType) => [
+ 'name' => [
+ 'label' => t($productType->name, category: 'site'),
+ 'url' => $productType->getCpEditUrl(),
+ ],
+ 'handle' => $productType->handle,
+ 'maxVariants' => $productType->maxVariants ?? '',
+ 'shippingCategories' => array_map(fn(ShippingCategory $category) => [
+ 'label' => t($category->name, category: 'site'),
+ 'url' => $canManageShipping ? $category->getCpEditUrl() : null,
+ ], $productType->getShippingCategories()),
+ 'taxCategories' => array_map(fn(TaxCategory $category) => [
+ 'label' => t($category->name, category: 'site'),
+ 'url' => $canManageTaxes ? $category->getCpEditUrl() : null,
+ ], $productType->getTaxCategories()),
+ ], app(ProductTypes::class)->getAllProductTypes());
+
+ $title = t('Product Types', category: 'commerce');
+
+ $form = Form::make([
+ Table::make('product-types')
+ ->columns([
+ ['key' => 'name', 'label' => t('Name')],
+ ['key' => 'handle', 'label' => t('Handle')],
+ ['key' => 'maxVariants', 'label' => t('Max Variants', category: 'commerce')],
+ ['key' => 'shippingCategories', 'label' => t('Available Shipping Categories', category: 'commerce')],
+ ['key' => 'taxCategories', 'label' => t('Available Tax Categories', category: 'commerce')],
+ ])
+ ->rows(array_values($rows))
+ ->emptyMessage(t('No product types exist yet.', category: 'commerce'))
+ ->createAction(
+ $this->readOnly ? null : t('New product type', category: 'commerce'),
+ $this->readOnly ? null : cp_url('commerce/settings/producttypes/new'),
+ ),
+ ]);
+
+ return $this->cpScreenResponse()
+ ->title($title)
+ ->crumbs($this->crumbs())
+ ->inertiaPage('Form', [
+ 'form' => $this->formResolver->resolve($form, new FormContext()),
+ ]);
}
public function editProductType(?int $productTypeId = null): CpScreenResponse
@@ -58,48 +111,384 @@ public function editProductType(?int $productTypeId = null): CpScreenResponse
}
$title = $productTypeId ? $productType->name : t('Create a new product type', category: 'commerce');
-
- \Craft::$app->getView()->registerAssetBundle(EditSectionAsset::class);
-
- return new CpScreenResponse()
+ $values = $this->initialValues($productType, $brandNewProductType);
+
+ $form = $this->formResolver->resolve(
+ $this->buildForm($productType, $values, $brandNewProductType),
+ new FormContext(
+ values: $values,
+ mode: $this->readOnly ? ControlMode::ReadOnly : ControlMode::Editable,
+ refreshable: !$this->readOnly,
+ ),
+ );
+
+ return $this->cpScreenResponse()
->title($title)
- ->crumbs([
- ['label' => t('Commerce', category: 'commerce'), 'url' => 'commerce'],
- ['label' => t('Settings'), 'url' => 'commerce/settings', 'ariaLabel' => t('Commerce Settings', category: 'commerce')],
- ['label' => t('Product Types', category: 'commerce'), 'url' => 'commerce/settings/producttypes'],
- ])
- ->tabs([
- 'productTypeSettings' => [
- 'label' => t('Settings'),
- 'url' => '#product-type-settings',
+ ->crumbs($brandNewProductType ? $this->crumbs() : $this->crumbs(['label' => $title]))
+ ->redirectUrl('commerce/settings/producttypes')
+ ->inertiaPage('Form', [
+ 'form' => $form,
+ 'submit' => [
+ 'method' => 'post',
+ 'url' => action([self::class, 'saveProductType']),
],
- 'taxAndShipping' => [
- 'label' => t('Tax & Shipping', category: 'commerce'),
- 'url' => '#tax-and-shipping',
+ 'refreshUrl' => $this->readOnly ? null : action([self::class, 'renderForm']),
+ ]);
+ }
+
+ /**
+ * Re-resolves the {@see editProductType()} Form tree for the values currently in progress
+ * on the client, so toggles like `isStructure`/`hasProductTitleField` can branch which
+ * fields appear next without a full page reload.
+ */
+ public function renderForm(Request $request): JsonResponse
+ {
+ // Validate for shape only — `Request::validate()` returns just the ruled subset
+ // (`validated()`), which would silently strip every field but `productTypeId` back
+ // out of `values`. Reading the raw input keeps the full posted values intact, which
+ // `buildForm()`'s branching (and every other control's live value) depends on.
+ $request->validate([
+ 'values' => ['required', 'array'],
+ 'values.productTypeId' => ['nullable', 'integer'],
+ 'scope' => ['present', 'array', 'size:0'],
+ ]);
+
+ $values = $request->input('values');
+ $productTypeId = $values['productTypeId'] ?? null;
+
+ if ($productTypeId) {
+ $productType = app(ProductTypes::class)->getProductTypeById((int) $productTypeId);
+ abort_if(!$productType, 404);
+ } else {
+ $productType = new ProductType();
+ }
+
+ // The client only posts values for controls currently in the rendered tree — a field
+ // hidden behind a toggle that's about to flip back on is absent, not merely empty. Layer
+ // the posted values over this model's real defaults so a newly-revealed field (e.g.
+ // `defaultPlacement` when `isStructure` flips on) gets its actual value instead of null.
+ $values = array_replace($this->initialValues($productType, brandNew: !$productTypeId), $values);
+
+ $form = $this->formResolver->resolve(
+ $this->buildForm($productType, $values, brandNew: !$productTypeId),
+ new FormContext(
+ values: $values,
+ mode: ControlMode::Editable,
+ refreshable: true,
+ ),
+ );
+
+ return new JsonResponse(['form' => $form]);
+ }
+
+ /**
+ * The Form's starting values, derived from a loaded (or brand new) {@see ProductType}.
+ * Only used for the initial page load — {@see renderForm()} uses the client's own
+ * in-progress values instead, so unsaved edits aren't clobbered on every toggle.
+ *
+ * @return array
+ */
+ private function initialValues(ProductType $productType, bool $brandNew): array
+ {
+ $siteSettings = $productType->getSiteSettings();
+ $siteRows = [];
+
+ foreach (Sites::getAllSites() as $site) {
+ $settings = $siteSettings[$site->id] ?? null;
+
+ $siteRows[$site->handle] = [
+ 'name' => $site->getName(),
+ 'enabled' => $brandNew || $settings !== null,
+ 'uriFormat' => $settings->uriFormat ?? '',
+ 'template' => $settings->template ?? '',
+ 'enabledByDefault' => $settings === null || $settings->enabledByDefault,
+ ];
+ }
+
+ $productFieldLayout = $productType->getProductFieldLayout();
+ $variantFieldLayout = $productType->getVariantFieldLayout();
+
+ return [
+ 'productTypeId' => $productType->id,
+ 'name' => $productType->name,
+ 'handle' => $productType->handle,
+ 'isStructure' => $productType->isStructure,
+ 'defaultPlacement' => $productType->defaultPlacement,
+ 'maxLevels' => $productType->maxLevels ?? '',
+ 'enableVersioning' => $productType->enableVersioning,
+ 'hasProductTitleField' => $productType->hasProductTitleField,
+ 'productTitleFormat' => $productType->productTitleFormat,
+ 'productTitleTranslationMethod' => $productType->productTitleTranslationMethod,
+ 'productTitleTranslationKeyFormat' => $productType->productTitleTranslationKeyFormat ?? '',
+ 'productUiLabelFormat' => $productType->productUiLabelFormat,
+ 'showSlugField' => $productType->showSlugField,
+ 'slugTranslationMethod' => $productType->slugTranslationMethod,
+ 'slugTranslationKeyFormat' => $productType->slugTranslationKeyFormat ?? '',
+ 'skuFormat' => $productType->skuFormat ?? '',
+ 'descriptionFormat' => $productType->descriptionFormat,
+ 'maxVariants' => $productType->maxVariants ?? '',
+ 'hasDimensions' => $productType->hasDimensions,
+ 'hasVariantTitleField' => $productType->hasVariantTitleField,
+ 'variantTitleFormat' => $productType->variantTitleFormat,
+ 'variantTitleTranslationMethod' => $productType->variantTitleTranslationMethod,
+ 'variantTitleTranslationKeyFormat' => $productType->variantTitleTranslationKeyFormat ?? '',
+ 'variantUiLabelFormat' => $productType->variantUiLabelFormat,
+ 'sites' => $siteRows,
+ 'previewTargets' => $productType->previewTargets ?? [],
+ 'propagationMethod' => $productType->propagationMethod->value,
+ 'fieldLayout' => [
+ 'id' => $productFieldLayout->id,
+ 'uid' => $productFieldLayout->uid,
+ ...($productFieldLayout->getConfig() ?? []),
+ ],
+ 'variant-layout' => [
+ 'fieldLayout' => [
+ 'id' => $variantFieldLayout->id,
+ 'uid' => $variantFieldLayout->uid,
+ ...($variantFieldLayout->getConfig() ?? []),
],
- 'productFields' => [
- 'label' => t('Product Fields', category: 'commerce'),
- 'url' => '#product-fields',
+ ],
+ ];
+ }
+
+ /**
+ * Builds the edit screen's Form tree, branching on `$values` (rather than reading straight
+ * off `$productType`) so {@see editProductType()} and {@see renderForm()} produce the exact
+ * same shape for the exact same values — one from a loaded model, the other from whatever the
+ * client just posted mid-edit.
+ *
+ * @param array $values
+ */
+ private function buildForm(ProductType $productType, array $values, bool $brandNew): Form
+ {
+ $isMultiSite = Sites::isMultiSite();
+
+ $handle = Handle::make('handle');
+
+ if ($brandNew) {
+ $handle->source('name');
+ }
+
+ $isStructureField = Field::make(
+ t('Enable structure for products of this type', category: 'commerce'),
+ Lightswitch::make('isStructure'),
+ );
+
+ if ($productType->id) {
+ $isStructureField->warning(t('Changing this may result in data loss.'));
+ }
+
+ $settingsFields = [
+ HiddenField::make('productTypeId'),
+ Field::make(t('Name', category: 'commerce'), Text::make('name')->autofocus())
+ ->instructions(t('What this product type will be called in the control panel.', category: 'commerce'))
+ ->required(),
+ Field::make(t('Handle', category: 'commerce'), $handle)
+ ->instructions(t('How you’ll refer to this product type in the templates.', category: 'commerce'))
+ ->required(),
+ $isStructureField,
+ ];
+
+ if ($values['isStructure'] ?? false) {
+ $settingsFields[] = Field::make(
+ t('Default {type} Placement', ['type' => t('Product', category: 'commerce')]),
+ Choice::make('defaultPlacement')->options([
+ ['value' => 'beginning', 'label' => t('Before other {type}', ['type' => t('products', category: 'commerce')])],
+ ['value' => 'end', 'label' => t('After other {type}', ['type' => t('products', category: 'commerce')])],
+ ]),
+ )->instructions(t('Where new {type} should be placed by default in the structure.', ['type' => t('products', category: 'commerce')]));
+
+ $settingsFields[] = Field::make(t('Max Levels'), Number::make('maxLevels')->min(1)->max(32767)->size(5))
+ ->instructions(t('The maximum number of levels this product type can have. Leave blank if you don’t care.', category: 'commerce'));
+ }
+
+ $settingsFields[] = Field::make(t('Enable versioning for products of this type', category: 'commerce'), Lightswitch::make('enableVersioning'));
+ $settingsFields[] = Field::make(t('Show the Title field for products', category: 'commerce'), Lightswitch::make('hasProductTitleField'));
+
+ if ($values['hasProductTitleField'] ?? false) {
+ if ($isMultiSite) {
+ $settingsFields[] = Field::make(
+ t('{name} Translation Method', ['name' => t('Title')]),
+ Choice::make('productTitleTranslationMethod')->options(TranslationMethod::asOptions()),
+ )->instructions(t('How should {name} values be translated?', ['name' => t('Title')]));
+
+ if (($values['productTitleTranslationMethod'] ?? null) === TranslationMethod::Custom->value) {
+ $settingsFields[] = Field::make(
+ t('{name} Translation Key Format', ['name' => t('Title')]),
+ Text::make('productTitleTranslationKeyFormat')->monospace(),
+ )->instructions(t('Template that defines the {name} field’s custom “translation key” format. Values will be copied to all sites that produce the same key.', ['name' => t('Title')]));
+ }
+ }
+ } else {
+ $settingsFields[] = Field::make(t('Product Title Format', category: 'commerce'), Text::make('productTitleFormat')->monospace())
+ ->instructions(t('What the auto-generated product titles should look like. You can include tags that output product properties, such as {ex1} or {ex2}. All custom fields used must be set to required.', [
+ 'ex1' => Html::code('{sku}'),
+ 'ex2' => Html::code('{myProductsCustomField}'),
+ ], category: 'commerce'));
+ }
+
+ $settingsFields[] = Field::make(t('UI Label Format'), Text::make('productUiLabelFormat')->monospace())
+ ->instructions(t('How products should be labeled within the control panel.', category: 'commerce'));
+ $settingsFields[] = Field::make(t('Show the Slug field'), Lightswitch::make('showSlugField'));
+
+ if ($isMultiSite && ($values['showSlugField'] ?? false)) {
+ $settingsFields[] = Field::make(
+ t('{name} Translation Method', ['name' => t('Slug')]),
+ Choice::make('slugTranslationMethod')->options(TranslationMethod::asOptions()),
+ )->instructions(t('How should {name} values be translated?', ['name' => t('Slug')]));
+
+ if (($values['slugTranslationMethod'] ?? null) === TranslationMethod::Custom->value) {
+ $settingsFields[] = Field::make(
+ t('{name} Translation Key Format', ['name' => t('Slug')]),
+ Text::make('slugTranslationKeyFormat')->monospace(),
+ )->instructions(t('Template that defines the {name} field’s custom "translation key" format. Values will be copied to all sites that produce the same key.', ['name' => t('Slug')]));
+ }
+ }
+
+ $settingsFields[] = Field::make(t('Automatic SKU Format', category: 'commerce'), Text::make('skuFormat')->monospace())
+ ->instructions(t('What the unique auto-generated SKUs should look like, when a SKU field is submitted without a value. You can include tags that output properties, such as {ex1} or {ex2}', [
+ 'ex1' => Html::code('{product.slug}'),
+ 'ex2' => Html::code('{myVariantCustomField}'),
+ ], category: 'commerce'));
+ $settingsFields[] = Field::make(t('Order Description Format', category: 'commerce'), Text::make('descriptionFormat')->monospace())
+ ->instructions(t('How this product will be described on a line item in an order. You can include tags that output properties, such as {ex1} or {ex2}', [
+ 'ex1' => Html::code('{product.title}'),
+ 'ex2' => Html::code('{myVariantCustomField}'),
+ ], category: 'commerce'));
+ $settingsFields[] = Field::make(t('Max Variants', category: 'commerce'), Number::make('maxVariants')->size(2));
+ $settingsFields[] = Field::make(t('Show the Dimensions and Weight fields for products of this type', category: 'commerce'), Lightswitch::make('hasDimensions'));
+ $settingsFields[] = Separator::make('variant-settings-separator');
+ $settingsFields[] = Field::make(t('Show the Title field for variants', category: 'commerce'), Lightswitch::make('hasVariantTitleField'));
+
+ if ($values['hasVariantTitleField'] ?? false) {
+ if ($isMultiSite) {
+ $settingsFields[] = Field::make(
+ t('{name} Translation Method', ['name' => t('Title')]),
+ Choice::make('variantTitleTranslationMethod')->options(TranslationMethod::asOptions()),
+ )->instructions(t('How should {name} values be translated?', ['name' => t('Title')]));
+
+ if (($values['variantTitleTranslationMethod'] ?? null) === TranslationMethod::Custom->value) {
+ $settingsFields[] = Field::make(
+ t('{name} Translation Key Format', ['name' => t('Title')]),
+ Text::make('variantTitleTranslationKeyFormat')->monospace(),
+ )->instructions(t('Template that defines the {name} field’s custom “translation key” format. Values will be copied to all sites that produce the same key.', ['name' => t('Title')]));
+ }
+ }
+ } else {
+ $settingsFields[] = Field::make(t('Variant Title Format', category: 'commerce'), Text::make('variantTitleFormat')->monospace())
+ ->instructions(t('What the auto-generated variant titles should look like. You can include tags that output variant properties, such as {ex1} or {ex2}. All custom fields used must be set to required.', [
+ 'ex1' => Html::code('{sku}'),
+ 'ex2' => Html::code('{myVariantsCustomField}'),
+ ], category: 'commerce'));
+ }
+
+ $settingsFields[] = Field::make(t('Variant UI Label Format', category: 'commerce'), Text::make('variantUiLabelFormat')->monospace())
+ ->instructions(t('How variants should be labeled within the control panel.', category: 'commerce'));
+ $settingsFields[] = Separator::make('site-settings-separator');
+ $settingsFields[] = Heading::make('site-settings-heading', t('Site Settings'))
+ ->description(t('Choose which sites this product type should be available in, and configure the site-specific settings.', category: 'commerce'));
+ $settingsFields[] = Field::make(control: TableControl::make('sites')
+ ->keyed()
+ ->columns([
+ 'name' => ['heading' => t('Site'), 'type' => 'heading'],
+ 'enabled' => ['heading' => t('Enabled'), 'type' => 'lightswitch'],
+ 'uriFormat' => [
+ 'heading' => t('Product URI Format', category: 'commerce'),
+ 'type' => 'singleline',
+ 'info' => t('What product URIs should look like for the site.', category: 'commerce'),
],
- 'variantFields' => [
- 'label' => t('Variant Fields', category: 'commerce'),
- 'url' => '#variant-fields',
+ 'template' => [
+ 'heading' => t('Template'),
+ 'type' => 'template',
+ 'options' => SelectOptions::getTemplateSuggestions(),
+ 'info' => t('Which template should be loaded when a product’s URL is requested.', category: 'commerce'),
],
+ 'enabledByDefault' => ['heading' => t('Default Status'), 'type' => 'lightswitch'],
+ ]));
+
+ if (!$this->generalConfig->headlessMode) {
+ $settingsFields[] = Separator::make('preview-targets-separator');
+ $settingsFields[] = Heading::make('preview-targets-heading', t('Preview Targets'))
+ ->description(t('Locations that should be available for previewing products in this product type.', category: 'commerce'));
+ $settingsFields[] = Field::make(control: TableControl::make('previewTargets')
+ ->columns([
+ 'label' => ['heading' => t('Label'), 'type' => 'singleline'],
+ 'urlFormat' => [
+ 'heading' => t('URL Format'),
+ 'type' => 'singleline',
+ 'info' => t('The URL/URI to use for this target.'),
+ ],
+ 'refresh' => ['heading' => t('Auto-refresh'), 'type' => 'lightswitch'],
+ ])
+ ->allowAdd()
+ ->allowDelete()
+ ->allowReorder());
+ }
+
+ if ($isMultiSite) {
+ $settingsFields[] = Field::make(
+ t('Propagation Method'),
+ Choice::make('propagationMethod')->options([
+ ['value' => 'none', 'label' => t('Only save product to the site they were created in', category: 'commerce')],
+ ['value' => 'siteGroup', 'label' => t('Save product to other sites in the same site group', category: 'commerce')],
+ ['value' => 'language', 'label' => t('Save product to other sites with the same language', category: 'commerce')],
+ ['value' => 'all', 'label' => t('Save product to all sites enabled for this product type', category: 'commerce')],
+ ['value' => 'custom', 'label' => t('Let each product choose which sites it should be saved to', category: 'commerce')],
+ ]),
+ )->instructions(t('Of the enabled sites above, which sites should products in this product type be saved to?', category: 'commerce'));
+ }
+
+ return Form::make()
+ ->addTab(t('Settings'), $settingsFields)
+ ->addTab(t('Tax & Shipping', category: 'commerce'), [
+ Heading::make('shipping-categories-heading', t('Available Shipping Categories', category: 'commerce')),
+ TemplateContent::make('shipping-categories', $this->categoriesHtml(
+ $productType->getShippingCategories(),
+ (bool) currentUser()?->can('commerce-manageShipping'),
+ )),
+ Separator::make('tax-shipping-separator'),
+ Heading::make('tax-categories-heading', t('Available Tax Categories', category: 'commerce')),
+ TemplateContent::make('tax-categories', $this->categoriesHtml(
+ $productType->getTaxCategories(),
+ (bool) currentUser()?->can('commerce-manageTaxes'),
+ )),
])
- ->selectedSubnavItem('settings')
- ->action('commerce/product-types/save-product-type')
- ->submitButtonLabel(t('Save'))
- ->redirectUrl('commerce/settings/producttypes')
- ->contentTemplate('commerce/settings/producttypes/_edit', [
- 'productTypeId' => $productTypeId,
- 'productType' => $productType,
- 'brandNewProductType' => $brandNewProductType,
- 'title' => $title,
- 'selectedTab' => 'productTypeSettings',
- 'readOnly' => $this->readOnly,
+ ->addTab(t('Product Fields', category: 'commerce'), [
+ Field::make(null, FieldLayoutDesigner::make('fieldLayout')
+ ->elementType(Product::class)
+ ->withCardViewDesigner()),
+ ])
+ ->addTab(t('Variant Fields', category: 'commerce'), [
+ Field::make(null, FieldLayoutDesigner::make('variant-layout.fieldLayout')
+ ->elementType(Variant::class)
+ ->withCardViewDesigner()),
]);
}
+ /**
+ * @param ShippingCategory[]|TaxCategory[] $categories
+ *
+ * Category names are user-entered, so every name is run through {@see Html::encode()}
+ * before it's embedded — `Html::tag()`/`Html::a()` don't encode their content themselves
+ * (they assume the caller already has), and this content is otherwise-unsanitized HTML by
+ * the time {@see TemplateContent::make()} sanitizes it, so this can't rely on that alone.
+ */
+ private function categoriesHtml(array $categories, bool $canManage): string
+ {
+ if (empty($categories)) {
+ return Html::tag('p', Html::encode(t('None', category: 'commerce')));
+ }
+
+ $items = implode('', array_map(
+ fn($category) => Html::tag('li', $canManage
+ ? Html::a(Html::encode(t($category->name, category: 'site')), $category->getCpEditUrl())
+ : Html::encode(t($category->name, category: 'site'))),
+ $categories,
+ ));
+
+ return Html::tag('ul', $items, ['class' => 'bullets']);
+ }
+
public function saveProductType(Request $request): ?Response
{
abort_unless(currentUser()?->can('manageCommerce'), 403, t('This action is not allowed for the current user.', category: 'commerce'));
diff --git a/src/Http/Controllers/Settings/SettingsController.php b/src/Http/Controllers/Settings/SettingsController.php
deleted file mode 100644
index ea5a5b6725..0000000000
--- a/src/Http/Controllers/Settings/SettingsController.php
+++ /dev/null
@@ -1,98 +0,0 @@
-readOnly = !$generalConfig->allowAdminChanges;
- }
-
- public function edit(): string
- {
- return pageTemplate('commerce/settings/general', [
- 'settings' => $this->plugin->getSettings(),
- 'readOnly' => $this->readOnly,
- ], TemplateMode::Cp);
- }
-
- public function saveSettings(Request $request): Response|string
- {
- $plugin = $this->plugin;
- $settings = $request->input('settings');
- $pluginSettingsSaved = Plugins::savePluginSettings($plugin, $settings);
-
- if (!$pluginSettingsSaved) {
- return pageTemplate('commerce/settings/general/index', ['settings' => $plugin->getSettings()], TemplateMode::Cp);
- }
-
- return $this->asSuccess(t('Settings saved.', category: 'commerce'));
- }
-
- public function saveTransferSettings(): Response
- {
- $fieldLayout = Fields::assembleLayoutFromPost();
-
- $fieldLayout->reservedFieldHandles = [
- 'originLocationId',
- 'originLocation',
- 'destinationLocationId',
- 'destinationLocation',
- ];
-
- $fieldLayout->type = Transfer::class;
-
- if (!$fieldLayout->validate()) {
- return $this->asFailure(t('Couldn\'t save transfer fields.', category: 'commerce'));
- }
-
- if ($currentTransfersFieldLayout = ProjectConfig::get(Transfers::CONFIG_FIELDLAYOUT_KEY)) {
- $uid = array_key_first($currentTransfersFieldLayout);
- } else {
- $uid = (string)Str::uuid();
- }
-
- $configData = [$uid => $fieldLayout->getConfig()];
- $result = ProjectConfig::set(Transfers::CONFIG_FIELDLAYOUT_KEY, $configData, force: true);
-
- if (!$result) {
- return $this->asFailure(t('Couldn\'t save transfer fields.'));
- }
-
- return $this->asSuccess(t('Transfer fields saved.', category: 'commerce'));
- }
-
- public function editTransferSettings(): string
- {
- $fieldLayout = app(Transfers::class)->getFieldLayout();
-
- return pageTemplate('commerce/settings/transfers/_edit', [
- 'fieldLayout' => $fieldLayout,
- 'title' => t('Transfer Settings', category: 'commerce'),
- 'readOnly' => $this->readOnly,
- ], TemplateMode::Cp);
- }
-}
diff --git a/src/Http/Controllers/Settings/StoresController.php b/src/Http/Controllers/Settings/StoresController.php
index 39b96a69c2..8fee4fce4f 100644
--- a/src/Http/Controllers/Settings/StoresController.php
+++ b/src/Http/Controllers/Settings/StoresController.php
@@ -5,36 +5,43 @@
namespace CraftCms\Commerce\Http\Controllers\Settings;
use craft\db\Query;
-use CraftCms\Cms\Config\GeneralConfig;
-use CraftCms\Cms\Http\RespondsWithFlash;
+use CraftCms\Cms\Form\Controls\Choice;
+use CraftCms\Cms\Form\Controls\Handle;
+use CraftCms\Cms\Form\Controls\Lightswitch;
+use CraftCms\Cms\Form\Controls\Table as TableControl;
+use CraftCms\Cms\Form\Controls\Text;
+use CraftCms\Cms\Form\Enums\ControlMode;
+use CraftCms\Cms\Form\Form;
+use CraftCms\Cms\Form\FormContext;
+use CraftCms\Cms\Form\Nodes\Field;
+use CraftCms\Cms\Form\Nodes\HiddenField;
+use CraftCms\Cms\Form\Nodes\Table;
+use CraftCms\Cms\Http\Responses\CpScreenResponse;
use CraftCms\Cms\Support\Facades\Sites;
+use CraftCms\Cms\Support\Html;
use CraftCms\Cms\Support\Json;
use CraftCms\Cms\Support\Url;
-use CraftCms\Cms\View\TemplateMode;
use CraftCms\Commerce\CatalogPricing\CatalogPricingRules;
-use CraftCms\Commerce\Database\Table;
+use CraftCms\Commerce\Database\Table as DbTable;
use CraftCms\Commerce\Order\Elements\Order;
use CraftCms\Commerce\Payment\Currencies;
+use CraftCms\Commerce\Plugin;
use CraftCms\Commerce\Store\Data\Store;
use CraftCms\Commerce\Store\Stores;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
-use function CraftCms\Cms\pageTemplate;
+use function CraftCms\Cms\cp_url;
use function CraftCms\Cms\t;
-readonly class StoresController
+class StoresController extends BaseSettingsController
{
- use RespondsWithFlash;
-
- private bool $readOnly;
-
- public function __construct(GeneralConfig $generalConfig)
+ protected function getSectionCrumb(): array
{
- $this->readOnly = !$generalConfig->allowAdminChanges;
+ return ['label' => t('Stores'), 'href' => cp_url('commerce/settings/stores')];
}
- public function editStore(?int $storeId = null): string
+ public function editStore(?int $storeId = null): CpScreenResponse
{
$storesService = app(Stores::class);
@@ -54,12 +61,6 @@ public function editStore(?int $storeId = null): string
$title = t('Create a new Store');
}
- $crumbs = [
- ['label' => t('Commerce', category: 'commerce'), 'url' => Url::url('commerce')],
- ['label' => t('Settings', category: 'commerce'), 'url' => Url::url('commerce/settings')],
- ['label' => t('Stores'), 'url' => Url::url('commerce/settings/stores')],
- ];
-
$hasOrders = $storeModel->id && Order::find()
->trashed(null)
->storeId($storeModel->id)
@@ -80,18 +81,145 @@ public function editStore(?int $storeId = null): string
$currencyOptions = app(Currencies::class)->getAllCurrenciesList();
- return pageTemplate('commerce/settings/stores/_edit', [
- 'brandNewStore' => $brandNewStore,
- 'allowCurrencyChange' => $allowCurrencyChange,
- 'title' => $title,
- 'crumbs' => $crumbs,
- 'store' => $storeModel,
- 'currencyOptions' => $currencyOptions,
- 'availableSiteOptions' => $availableSiteOptions,
- 'freeOrderPaymentStrategyOptions' => $storeModel->getFreeOrderPaymentStrategyOptions(),
- 'minimumTotalPriceStrategyOptions' => $storeModel->getMinimumTotalPriceStrategyOptions(),
- 'readOnly' => $this->readOnly,
- ], TemplateMode::Cp);
+ $form = $this->buildStoreForm($storeModel, $brandNewStore, $allowCurrencyChange, $availableSiteOptions, $currencyOptions);
+ $values = $this->storeInitialValues($storeModel);
+
+ return $this->cpScreenResponse()
+ ->title($title)
+ ->crumbs($brandNewStore ? $this->crumbs() : $this->crumbs(['label' => $title]))
+ ->redirectUrl('commerce/settings/stores')
+ ->inertiaPage('Form', [
+ 'form' => $this->formResolver->resolve($form, new FormContext(
+ values: $values,
+ mode: $this->readOnly ? ControlMode::ReadOnly : ControlMode::Editable,
+ )),
+ 'submit' => [
+ 'method' => 'post',
+ 'url' => action([self::class, 'saveStore']),
+ ],
+ ]);
+ }
+
+ /**
+ * @param list $availableSiteOptions
+ * @param list $currencyOptions
+ */
+ private function buildStoreForm(
+ Store $storeModel,
+ bool $brandNewStore,
+ bool $allowCurrencyChange,
+ array $availableSiteOptions,
+ array $currencyOptions,
+ ): Form {
+ $currencyControl = Choice::make('currency')->options($currencyOptions);
+
+ if (!$allowCurrencyChange) {
+ $currencyControl->mode(ControlMode::Disabled);
+ }
+
+ $currencyField = Field::make(t('Currency', category: 'commerce'), $currencyControl)->required();
+
+ if (!$allowCurrencyChange) {
+ $currencyField->tip(t('The primary currency cannot be changed after orders are placed.', category: 'commerce'));
+ }
+
+ $handle = Handle::make('handle');
+
+ if ($brandNewStore) {
+ $handle->source('name');
+ }
+
+ $storeFields = [
+ $brandNewStore ? null : HiddenField::make('storeId'),
+ Field::make(t('Name', category: 'commerce'), Text::make('name')->autofocus())
+ ->required(),
+ Field::make(t('Handle', category: 'app'), $handle)
+ ->instructions(t('How you’ll refer to this store in the templates.', category: 'app'))
+ ->required(),
+ $brandNewStore
+ ? Field::make(t('Sites', category: 'commerce'), Choice::make('siteId')->options($availableSiteOptions))
+ ->instructions(t('Every new store must be assigned to at least one site.', category: 'commerce'))
+ : null,
+ $currencyField,
+ $storeModel->primary
+ ? null
+ : Field::make(t('Make this the primary store', category: 'commerce'), Lightswitch::make('primary')),
+ ];
+
+ $settingsFields = [
+ Field::make(t('Auto Set New Cart Addresses', category: 'commerce'), Lightswitch::make('autoSetNewCartAddresses'))
+ ->instructions(t('Whether the user’s primary shipping and billing addresses should be set automatically on new carts.', category: 'commerce')),
+ Field::make(t('Auto Set Cart Shipping Method Option', category: 'commerce'), Lightswitch::make('autoSetCartShippingMethodOption'))
+ ->instructions(t('Whether the first available shipping method option should be set automatically on carts.', category: 'commerce')),
+ Field::make(t('Auto Set Payment Source', category: 'commerce'), Lightswitch::make('autoSetPaymentSource'))
+ ->instructions(t('Whether the user’s primary payment source should be set automatically on new carts.', category: 'commerce')),
+ Field::make(t('Allow Empty Cart On Checkout', category: 'commerce'), Lightswitch::make('allowEmptyCartOnCheckout')),
+ Field::make(t('Allow Checkout Without Payment', category: 'commerce'), Lightswitch::make('allowCheckoutWithoutPayment')),
+ Field::make(t('Allow Partial Payment On Checkout', category: 'commerce'), Lightswitch::make('allowPartialPaymentOnCheckout')),
+ Field::make(t('Free Order Payment Strategy', category: 'commerce'), Choice::make('freeOrderPaymentStrategy')
+ ->options(self::choiceOptions($storeModel->getFreeOrderPaymentStrategyOptions())))
+ ->instructions(t('Strategy to apply when an order is free or has a zero balance.', category: 'commerce'))
+ ->required(),
+ Field::make(t('Minimum Total Price Strategy', category: 'commerce'), Choice::make('minimumTotalPriceStrategy')
+ ->options(self::choiceOptions($storeModel->getMinimumTotalPriceStrategyOptions())))
+ ->instructions(t('Strategy to apply when calculating the minimum order price.', category: 'commerce'))
+ ->required(),
+ Field::make(t('Require Shipping Address At Checkout', category: 'commerce'), Lightswitch::make('requireShippingAddressAtCheckout')),
+ Field::make(t('Require Billing Address At Checkout', category: 'commerce'), Lightswitch::make('requireBillingAddressAtCheckout')),
+ Field::make(t('Require Shipping Method Selection At Checkout', category: 'commerce'), Lightswitch::make('requireShippingMethodSelectionAtCheckout')),
+ Field::make(t('Use Billing Address For Tax', category: 'commerce'), Lightswitch::make('useBillingAddressForTax')),
+ Field::make(t('Validate Business Tax ID as Vat ID', category: 'commerce'), Lightswitch::make('validateOrganizationTaxIdAsVatId')),
+ Field::make(t('Order Reference Number Format', category: 'commerce'), Text::make('orderReferenceFormat')->monospace())
+ ->instructions(t('A friendly reference number will be generated based on this format when a cart is completed and becomes an order. For example {ex1}, or {ex2}. The result of this format must be unique.', [
+ 'ex1' => '2018-{number[:7]}',
+ 'ex2' => "{{object.dateCompleted|date('y')}}-{{ seq(object.dateCompleted|date('y'), 8) }}",
+ ], category: 'commerce')),
+ ];
+
+ return Form::make()
+ ->addTab(t('Store', category: 'commerce'), array_values(array_filter($storeFields)))
+ ->addTab(t('Settings', category: 'commerce'), $settingsFields);
+ }
+
+ /** @return array */
+ private function storeInitialValues(Store $storeModel): array
+ {
+ return [
+ 'storeId' => $storeModel->id,
+ 'name' => $storeModel->getName(false),
+ 'handle' => $storeModel->handle,
+ 'siteId' => null,
+ 'currency' => $storeModel->getCurrency()?->getCode(),
+ 'primary' => $storeModel->primary,
+ 'autoSetNewCartAddresses' => $storeModel->getAutoSetNewCartAddresses(),
+ 'autoSetCartShippingMethodOption' => $storeModel->getAutoSetCartShippingMethodOption(),
+ 'autoSetPaymentSource' => $storeModel->getAutoSetPaymentSource(),
+ 'allowEmptyCartOnCheckout' => $storeModel->getAllowEmptyCartOnCheckout(),
+ 'allowCheckoutWithoutPayment' => $storeModel->getAllowCheckoutWithoutPayment(),
+ 'allowPartialPaymentOnCheckout' => $storeModel->getAllowPartialPaymentOnCheckout(),
+ 'freeOrderPaymentStrategy' => $storeModel->getFreeOrderPaymentStrategy(),
+ 'minimumTotalPriceStrategy' => $storeModel->getMinimumTotalPriceStrategy(),
+ 'requireShippingAddressAtCheckout' => $storeModel->getRequireShippingAddressAtCheckout(),
+ 'requireBillingAddressAtCheckout' => $storeModel->getRequireBillingAddressAtCheckout(),
+ 'requireShippingMethodSelectionAtCheckout' => $storeModel->getRequireShippingMethodSelectionAtCheckout(),
+ 'useBillingAddressForTax' => $storeModel->getUseBillingAddressForTax(),
+ 'validateOrganizationTaxIdAsVatId' => $storeModel->getValidateOrganizationTaxIdAsVatId(),
+ 'orderReferenceFormat' => $storeModel->getOrderReferenceFormat(),
+ ];
+ }
+
+ /**
+ * @param array $options Value-keyed label map, as returned by e.g.
+ * {@see Store::getFreeOrderPaymentStrategyOptions()}.
+ * @return list
+ */
+ private static function choiceOptions(array $options): array
+ {
+ return array_map(
+ fn(string $value, string $label) => ['value' => $value, 'label' => $label],
+ array_keys($options),
+ $options,
+ );
}
public function saveStore(Request $request): Response
@@ -132,7 +260,7 @@ public function saveStore(Request $request): Response
$store->uid = $savedStore->uid;
$store->sortOrder = $savedStore->sortOrder;
} elseif (!$storeId) {
- $store->sortOrder = new Query()->from(Table::STORES)->max('[[sortOrder]]') + 1;
+ $store->sortOrder = new Query()->from(DbTable::STORES)->max('[[sortOrder]]') + 1;
}
if (!$store->validate() || !$storesService->saveStore($store)) {
@@ -148,14 +276,10 @@ public function saveStore(Request $request): Response
return $this->asModelSuccess($store, t('Store saved.'), 'store');
}
- public function storesIndex(): string
+ public function storesIndex(): CpScreenResponse
{
$stores = app(Stores::class)->getAllStores();
- $crumbs = [
- ['label' => t('Commerce', category: 'commerce'), 'url' => Url::url('commerce')],
- ];
-
$menuItems = [];
$stores->each(function(Store $s) use (&$menuItems) {
$m = [];
@@ -178,14 +302,65 @@ public function storesIndex(): string
$menuItems[$s->handle] = $m;
});
- return pageTemplate('commerce/settings/stores/index', [
- 'stores' => $stores,
- 'crumbs' => $crumbs,
- 'sitesStores' => app(Stores::class)->getAllSiteStores(),
- 'primaryStoreId' => app(Stores::class)->getPrimaryStore()->id,
- 'menuItems' => $menuItems,
- 'readOnly' => $this->readOnly,
- ], TemplateMode::Cp);
+ $rows = $stores->map(fn(Store $s) => [
+ 'id' => $s->id,
+ 'name' => [
+ 'label' => t($s->getName(), category: 'site'),
+ 'url' => Url::cpUrl('commerce/settings/stores/' . $s->id),
+ ],
+ 'handle' => $s->handle,
+ 'sites' => $s->getSiteNames()->join(', '),
+ 'currency' => ['html' => Html::tag('code', Html::encode($s->getCurrency()?->getCode() ?? ''))],
+ 'primary' => $s->primary ? ['icon' => 'check', 'label' => t('Yes')] : '',
+ 'management' => [
+ 'label' => t('Store Management', category: 'commerce'),
+ 'items' => $menuItems[$s->handle],
+ ],
+ '_deletable' => !$s->primary,
+ ])->all();
+
+ $title = t('Stores');
+
+ $showNewStoreButton = !$this->readOnly && $stores->count() < count(Sites::getAllSites());
+
+ if ($showNewStoreButton) {
+ $showNewStoreButton = ($this->plugin->is(Plugin::EDITION_PRO, '=')
+ && $stores->count() < Plugin::EDITION_PRO_STORE_LIMIT
+ && app(CatalogPricingRules::class)->canUseCatalogPricingRules())
+ || ($this->plugin->is(Plugin::EDITION_ENTERPRISE, '=')
+ && app(CatalogPricingRules::class)->canUseCatalogPricingRules());
+ }
+
+ $form = Form::make([
+ Table::make('stores')
+ ->columns([
+ ['key' => 'name', 'label' => t('Name')],
+ ['key' => 'handle', 'label' => t('Handle')],
+ ['key' => 'sites', 'label' => t('Sites', category: 'commerce')],
+ ['key' => 'currency', 'label' => t('Currency', category: 'commerce')],
+ ['key' => 'primary', 'label' => t('Primary', category: 'commerce')],
+ ['key' => 'management', 'label' => t('Store Management', category: 'commerce')],
+ ])
+ ->rows($rows)
+ ->emptyMessage(t('No stores exist yet.', category: 'commerce'))
+ ->createAction(
+ $showNewStoreButton ? t('New store') : null,
+ $showNewStoreButton ? Url::cpUrl('commerce/settings/stores/new') : null,
+ )
+ ->when(!$this->readOnly, fn(Table $table) => $table
+ ->reorderable(action([self::class, 'reorderStores']))
+ ->deletable(
+ action([self::class, 'deleteStore']),
+ t('Are you sure you want to permanently delete this store and everything in it?', category: 'commerce'),
+ )),
+ ]);
+
+ return $this->cpScreenResponse()
+ ->title($title)
+ ->crumbs($this->crumbs())
+ ->inertiaPage('Form', [
+ 'form' => $this->formResolver->resolve($form, new FormContext()),
+ ]);
}
public function deleteStore(Request $request): Response
@@ -214,20 +389,54 @@ public function reorderStores(Request $request): Response
return $this->asSuccess();
}
- public function editSiteStores(): string
+ public function editSiteStores(): CpScreenResponse
{
- $crumbs = [
- ['label' => t('Commerce', category: 'commerce'), 'url' => Url::url('commerce')],
- ];
+ $storesService = app(Stores::class);
+ $sitesStores = $storesService->getAllSiteStores();
+ $primaryStoreId = $storesService->getPrimaryStore()->id;
+
+ $storeOptions = $storesService->getAllStores()->map(fn(Store $store) => [
+ 'label' => $store->getName(),
+ 'value' => $store->id,
+ ])->all();
+
+ $rows = [];
+
+ foreach (Sites::getAllSites() as $site) {
+ $siteStore = $sitesStores->count() > 0 ? $sitesStores->firstWhere('siteId', $site->id) : null;
+
+ $rows[$site->id] = [
+ 'site' => t($site->name, category: 'site'),
+ 'storeId' => $siteStore->storeId ?? $primaryStoreId,
+ ];
+ }
- return pageTemplate('commerce/settings/stores/_siteStore', [
- 'crumbs' => $crumbs,
- 'stores' => app(Stores::class)->getAllStores(),
- 'sites' => Sites::getAllSites(),
- 'sitesStores' => app(Stores::class)->getAllSiteStores(),
- 'primaryStoreId' => app(Stores::class)->getPrimaryStore()->id,
- 'readOnly' => $this->readOnly,
- ], TemplateMode::Cp);
+ $form = Form::make([
+ Field::make(null, TableControl::make('siteStores')
+ ->columns([
+ 'site' => ['type' => 'heading', 'heading' => t('Site')],
+ 'storeId' => ['type' => 'select', 'heading' => t('Store', category: 'commerce'), 'options' => $storeOptions],
+ ])
+ ->keyed()),
+ ]);
+
+ $values = ['siteStores' => $rows];
+ $title = t('Sites');
+
+ return $this->cpScreenResponse()
+ ->title($title)
+ ->crumbs($this->crumbs(['label' => $title]))
+ ->redirectUrl('commerce/settings/sites')
+ ->inertiaPage('Form', [
+ 'form' => $this->formResolver->resolve($form, new FormContext(
+ values: $values,
+ mode: $this->readOnly ? ControlMode::ReadOnly : ControlMode::Editable,
+ )),
+ 'submit' => [
+ 'method' => 'post',
+ 'url' => action([self::class, 'saveSiteStores']),
+ ],
+ ]);
}
public function saveSiteStores(Request $request): Response
@@ -238,7 +447,8 @@ public function saveSiteStores(Request $request): Response
foreach ($sitesStores as $siteStore) {
if (isset($siteStoresData[$siteStore->siteId])) {
- $siteStore->storeId = $siteStoresData[$siteStore->siteId]['storeId'];
+ $storeId = $siteStoresData[$siteStore->siteId]['storeId'];
+ $siteStore->storeId = $storeId !== null && $storeId !== '' ? (int) $storeId : null;
}
}
diff --git a/src/Http/Controllers/Settings/TransferSettingsController.php b/src/Http/Controllers/Settings/TransferSettingsController.php
new file mode 100644
index 0000000000..58cbf7cb79
--- /dev/null
+++ b/src/Http/Controllers/Settings/TransferSettingsController.php
@@ -0,0 +1,101 @@
+ t('Transfer Fields', category: 'commerce'), 'href' => cp_url('commerce/settings/transfers')];
+ }
+
+ public function saveTransferSettings(): Response
+ {
+ $fieldLayout = Fields::assembleLayoutFromPost();
+
+ $fieldLayout->reservedFieldHandles = [
+ 'originLocationId',
+ 'originLocation',
+ 'destinationLocationId',
+ 'destinationLocation',
+ ];
+
+ $fieldLayout->type = Transfer::class;
+
+ if (!$fieldLayout->validate()) {
+ return $this->asFailure(t('Couldn’t save transfer fields.', category: 'commerce'));
+ }
+
+ if ($currentTransfersFieldLayout = ProjectConfig::get(Transfers::CONFIG_FIELDLAYOUT_KEY)) {
+ $uid = array_key_first($currentTransfersFieldLayout);
+ } else {
+ $uid = (string)Str::uuid();
+ }
+
+ $configData = [$uid => $fieldLayout->getConfig()];
+ $result = ProjectConfig::set(Transfers::CONFIG_FIELDLAYOUT_KEY, $configData, force: true);
+
+ if (!$result) {
+ return $this->asFailure(t('Couldn’t save transfer fields.', category: 'commerce'));
+ }
+
+ return $this->asSuccess(t('Transfer fields saved.', category: 'commerce'));
+ }
+
+ public function editTransferSettings(): CpScreenResponse
+ {
+ $fieldLayout = app(Transfers::class)->getFieldLayout();
+
+ $form = Form::make([
+ Field::make(null, FieldLayoutDesigner::make('fieldLayout')
+ ->elementType(Transfer::class)
+ ->withCardViewDesigner()),
+ ]);
+
+ $form = $this->formResolver->resolve($form, new FormContext(
+ values: [
+ 'fieldLayout' => [
+ 'id' => $fieldLayout->id,
+ 'uid' => $fieldLayout->uid,
+ ...($fieldLayout->getConfig() ?? []),
+ ],
+ ],
+ mode: $this->generalConfig->allowAdminChanges ? ControlMode::Editable : ControlMode::ReadOnly,
+ ));
+
+ $title = t('Transfer Settings', category: 'commerce');
+
+ return $this->cpScreenResponse()
+ ->title($title)
+ ->crumbs($this->crumbs())
+ ->redirectUrl('commerce/settings/transfers')
+ ->inertiaPage('Form', [
+ 'form' => $form,
+ 'submit' => [
+ 'method' => 'post',
+ 'url' => action([self::class, 'saveTransferSettings']),
+ ],
+ ]);
+ }
+}
diff --git a/src/Http/Controllers/StoreManagement/BaseStoreManagementController.php b/src/Http/Controllers/StoreManagement/BaseStoreManagementController.php
new file mode 100644
index 0000000000..ef6cfd34f0
--- /dev/null
+++ b/src/Http/Controllers/StoreManagement/BaseStoreManagementController.php
@@ -0,0 +1,26 @@
+input('condition') ?? ['class' => CatalogPricingCondition::class];
$conditionBuilder = Conditions::createCondition($condition);
+ // Condition classes no longer self-render; ConditionBuilderRenderer replaces the old getBuilderHtml()/builderHtml().
$conditionBuilderHtml = new ConditionBuilderRenderer($conditionBuilder)->render();
$view = \Craft::$app->getView();
diff --git a/src/Http/Controllers/Settings/CatalogPricingRulesController.php b/src/Http/Controllers/StoreManagement/CatalogPricingRulesController.php
similarity index 95%
rename from src/Http/Controllers/Settings/CatalogPricingRulesController.php
rename to src/Http/Controllers/StoreManagement/CatalogPricingRulesController.php
index 8a2c455c96..4a4c4ed603 100644
--- a/src/Http/Controllers/Settings/CatalogPricingRulesController.php
+++ b/src/Http/Controllers/StoreManagement/CatalogPricingRulesController.php
@@ -2,11 +2,11 @@
declare(strict_types=1);
-namespace CraftCms\Commerce\Http\Controllers\Settings;
+namespace CraftCms\Commerce\Http\Controllers\StoreManagement;
use craft\helpers\Cp;
use craft\helpers\Localization;
-use CraftCms\Cms\Http\RespondsWithFlash;
+use CraftCms\Cms\Condition\ConditionBuilderRenderer;
use CraftCms\Cms\Http\Responses\CpScreenResponse;
use CraftCms\Cms\Support\DateTimeHelper;
use CraftCms\Cms\Support\Facades\Conditions;
@@ -26,7 +26,6 @@
use CraftCms\Commerce\CatalogPricing\Data\CatalogPricingRule;
use CraftCms\Commerce\CatalogPricing\Models\CatalogPricingRule as CatalogPricingRuleRecord;
use CraftCms\Commerce\Helpers\Currency;
-use CraftCms\Commerce\Http\Controllers\Concerns\HasStoreManagementScreen;
use CraftCms\Commerce\Payment\PaymentCurrencies;
use CraftCms\Commerce\Purchasable\Conditions\CatalogPricingRulePurchasableCondition;
use CraftCms\Commerce\Purchasable\Conditions\PurchasableConditionRule;
@@ -38,11 +37,8 @@
use function CraftCms\Cms\currentUserElement;
use function CraftCms\Cms\t;
-readonly class CatalogPricingRulesController
+readonly class CatalogPricingRulesController extends BaseStoreManagementController
{
- use HasStoreManagementScreen;
- use RespondsWithFlash;
-
public function index(?string $storeHandle = null): CpScreenResponse
{
$store = $this->resolveStore($storeHandle);
@@ -379,6 +375,12 @@ private function populateVariables(array $variables): array
['label' => t('Original promotional price', category: 'commerce'), 'value' => 'promotionalPrice'],
];
+ // Condition classes no longer self-render; ConditionBuilderRenderer replaces the old getBuilderHtml()/builderHtml().
+ $variables['productConditionHtml'] = new ConditionBuilderRenderer($catalogPricingRule->getProductCondition())->render();
+ $variables['variantConditionHtml'] = new ConditionBuilderRenderer($catalogPricingRule->getVariantCondition())->render();
+ $variables['purchasableConditionHtml'] = new ConditionBuilderRenderer($catalogPricingRule->getPurchasableCondition())->render();
+ $variables['customerConditionHtml'] = new ConditionBuilderRenderer($catalogPricingRule->getCustomerCondition())->render();
+
return $variables;
}
}
diff --git a/src/Http/Controllers/Settings/DiscountsController.php b/src/Http/Controllers/StoreManagement/DiscountsController.php
similarity index 97%
rename from src/Http/Controllers/Settings/DiscountsController.php
rename to src/Http/Controllers/StoreManagement/DiscountsController.php
index 2bf4e931aa..d4a3b44991 100644
--- a/src/Http/Controllers/Settings/DiscountsController.php
+++ b/src/Http/Controllers/StoreManagement/DiscountsController.php
@@ -2,15 +2,15 @@
declare(strict_types=1);
-namespace CraftCms\Commerce\Http\Controllers\Settings;
+namespace CraftCms\Commerce\Http\Controllers\StoreManagement;
use craft\commerce\web\assets\coupons\CouponsAsset;
use craft\db\Query;
use craft\elements\Category;
use craft\helpers\AdminTable;
+use CraftCms\Cms\Condition\ConditionBuilderRenderer;
use CraftCms\Cms\Edition;
use CraftCms\Cms\Entry\Elements\Entry;
-use CraftCms\Cms\Http\RespondsWithFlash;
use CraftCms\Cms\Http\Responses\CpScreenResponse;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\DateTimeHelper;
@@ -26,7 +26,6 @@
use CraftCms\Cms\View\Enums\Position;
use CraftCms\Commerce\Database\Table;
use CraftCms\Commerce\Helpers\Localization;
-use CraftCms\Commerce\Http\Controllers\Concerns\HasStoreManagementScreen;
use CraftCms\Commerce\Payment\Currencies;
use CraftCms\Commerce\Product\Elements\Product;
use CraftCms\Commerce\Promotion\Coupons;
@@ -46,11 +45,8 @@
use function CraftCms\Cms\currentUserElement;
use function CraftCms\Cms\t;
-readonly class DiscountsController
+readonly class DiscountsController extends BaseStoreManagementController
{
- use HasStoreManagementScreen;
- use RespondsWithFlash;
-
public const string DISCOUNT_COUNTER_TYPE_TOTAL = 'total';
public const string DISCOUNT_COUNTER_TYPE_EMAIL = 'email';
public const string DISCOUNT_COUNTER_TYPE_CUSTOMER = 'customer';
@@ -622,6 +618,12 @@ private function populateVariables(array &$variables): void
$variables['customerUsage'] = 0;
}
+ // Condition classes no longer self-render; ConditionBuilderRenderer replaces the old getBuilderHtml()/builderHtml().
+ $variables['orderConditionHtml'] = new ConditionBuilderRenderer($discount->getOrderCondition())->render();
+ $variables['customerConditionHtml'] = new ConditionBuilderRenderer($discount->getCustomerCondition())->render();
+ $variables['shippingAddressConditionHtml'] = new ConditionBuilderRenderer($discount->getShippingAddressCondition())->render();
+ $variables['billingAddressConditionHtml'] = new ConditionBuilderRenderer($discount->getBillingAddressCondition())->render();
+
$variables['categoryElementType'] = Category::class;
$variables['entryElementType'] = Entry::class;
diff --git a/src/Http/Controllers/Settings/PaymentCurrenciesController.php b/src/Http/Controllers/StoreManagement/PaymentCurrenciesController.php
similarity index 94%
rename from src/Http/Controllers/Settings/PaymentCurrenciesController.php
rename to src/Http/Controllers/StoreManagement/PaymentCurrenciesController.php
index fc7e6adcf8..52b396f48e 100644
--- a/src/Http/Controllers/Settings/PaymentCurrenciesController.php
+++ b/src/Http/Controllers/StoreManagement/PaymentCurrenciesController.php
@@ -2,15 +2,13 @@
declare(strict_types=1);
-namespace CraftCms\Commerce\Http\Controllers\Settings;
+namespace CraftCms\Commerce\Http\Controllers\StoreManagement;
use craft\helpers\Cp;
use craft\i18n\Formatter;
-use CraftCms\Cms\Http\RespondsWithFlash;
use CraftCms\Cms\Http\Responses\CpScreenResponse;
use CraftCms\Cms\Support\Facades\I18N;
use CraftCms\Cms\Support\Html;
-use CraftCms\Commerce\Http\Controllers\Concerns\HasStoreManagementScreen;
use CraftCms\Commerce\Order\Elements\Order;
use CraftCms\Commerce\Payment\Currencies;
use CraftCms\Commerce\Payment\Data\PaymentCurrency;
@@ -20,11 +18,8 @@
use Symfony\Component\HttpFoundation\Response;
use function CraftCms\Cms\t;
-readonly class PaymentCurrenciesController
+readonly class PaymentCurrenciesController extends BaseStoreManagementController
{
- use HasStoreManagementScreen;
- use RespondsWithFlash;
-
public function index(?string $storeHandle = null): CpScreenResponse
{
$store = $this->resolveStore($storeHandle);
diff --git a/src/Http/Controllers/Settings/SalesController.php b/src/Http/Controllers/StoreManagement/SalesController.php
similarity index 98%
rename from src/Http/Controllers/Settings/SalesController.php
rename to src/Http/Controllers/StoreManagement/SalesController.php
index f7e10aac95..dfdd1f6e56 100644
--- a/src/Http/Controllers/Settings/SalesController.php
+++ b/src/Http/Controllers/StoreManagement/SalesController.php
@@ -2,13 +2,12 @@
declare(strict_types=1);
-namespace CraftCms\Commerce\Http\Controllers\Settings;
+namespace CraftCms\Commerce\Http\Controllers\StoreManagement;
use craft\elements\Category;
use craft\helpers\Localization;
use CraftCms\Cms\Edition;
use CraftCms\Cms\Entry\Elements\Entry;
-use CraftCms\Cms\Http\RespondsWithFlash;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\DateTimeHelper;
use CraftCms\Cms\Support\Facades\Elements;
@@ -17,7 +16,6 @@
use CraftCms\Cms\Support\Json;
use CraftCms\Cms\Translation\Locale;
use CraftCms\Cms\View\TemplateMode;
-use CraftCms\Commerce\Http\Controllers\Concerns\HasStoreManagementScreen;
use CraftCms\Commerce\Payment\PaymentCurrencies;
use CraftCms\Commerce\Product\Elements\Product;
use CraftCms\Commerce\Product\Products;
@@ -36,11 +34,8 @@
use function CraftCms\Cms\pageTemplate;
use function CraftCms\Cms\t;
-readonly class SalesController
+readonly class SalesController extends BaseStoreManagementController
{
- use HasStoreManagementScreen;
- use RespondsWithFlash;
-
private function guard(): void
{
abort_unless(app(Sales::class)->canUseSales(), 403, 'Unable to use sales while using multi store or pricing rules.');
diff --git a/src/Http/Controllers/Settings/ShippingCategoriesController.php b/src/Http/Controllers/StoreManagement/ShippingCategoriesController.php
similarity index 97%
rename from src/Http/Controllers/Settings/ShippingCategoriesController.php
rename to src/Http/Controllers/StoreManagement/ShippingCategoriesController.php
index 9fe5bd4654..b96c9989a4 100644
--- a/src/Http/Controllers/Settings/ShippingCategoriesController.php
+++ b/src/Http/Controllers/StoreManagement/ShippingCategoriesController.php
@@ -2,10 +2,9 @@
declare(strict_types=1);
-namespace CraftCms\Commerce\Http\Controllers\Settings;
+namespace CraftCms\Commerce\Http\Controllers\StoreManagement;
use craft\helpers\Cp;
-use CraftCms\Cms\Http\RespondsWithFlash;
use CraftCms\Cms\Http\Responses\CpScreenResponse;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\Facades\HtmlStack;
@@ -13,7 +12,6 @@
use CraftCms\Cms\Support\Html as NewHtml;
use CraftCms\Cms\Support\Json;
use CraftCms\Cms\View\Enums\Position;
-use CraftCms\Commerce\Http\Controllers\Concerns\HasStoreManagementScreen;
use CraftCms\Commerce\Product\ProductType\ProductTypes;
use CraftCms\Commerce\Shipping\Data\ShippingCategory;
use CraftCms\Commerce\Shipping\ShippingCategories;
@@ -23,11 +21,8 @@
use Symfony\Component\HttpFoundation\Response;
use function CraftCms\Cms\t;
-readonly class ShippingCategoriesController
+readonly class ShippingCategoriesController extends BaseStoreManagementController
{
- use HasStoreManagementScreen;
- use RespondsWithFlash;
-
public function index(?string $storeHandle = null): CpScreenResponse
{
$store = $this->resolveStore($storeHandle);
diff --git a/src/Http/Controllers/Settings/ShippingMethodsController.php b/src/Http/Controllers/StoreManagement/ShippingMethodsController.php
similarity index 93%
rename from src/Http/Controllers/Settings/ShippingMethodsController.php
rename to src/Http/Controllers/StoreManagement/ShippingMethodsController.php
index bfaf9c23f7..dae8929094 100644
--- a/src/Http/Controllers/Settings/ShippingMethodsController.php
+++ b/src/Http/Controllers/StoreManagement/ShippingMethodsController.php
@@ -2,17 +2,16 @@
declare(strict_types=1);
-namespace CraftCms\Commerce\Http\Controllers\Settings;
+namespace CraftCms\Commerce\Http\Controllers\StoreManagement;
use craft\helpers\Cp;
-use CraftCms\Cms\Http\RespondsWithFlash;
+use CraftCms\Cms\Condition\ConditionBuilderRenderer;
use CraftCms\Cms\Http\Responses\CpScreenResponse;
use CraftCms\Cms\Support\Facades\HtmlStack;
use CraftCms\Cms\Support\Facades\I18N;
use CraftCms\Cms\Support\Html as NewHtml;
use CraftCms\Cms\Support\Json;
use CraftCms\Cms\View\Enums\Position;
-use CraftCms\Commerce\Http\Controllers\Concerns\HasStoreManagementScreen;
use CraftCms\Commerce\Shipping\Data\ShippingMethod;
use CraftCms\Commerce\Shipping\Models\ShippingMethod as ShippingMethodRecord;
use CraftCms\Commerce\Shipping\ShippingMethods;
@@ -23,11 +22,8 @@
use Symfony\Component\HttpFoundation\Response;
use function CraftCms\Cms\t;
-readonly class ShippingMethodsController
+readonly class ShippingMethodsController extends BaseStoreManagementController
{
- use HasStoreManagementScreen;
- use RespondsWithFlash;
-
public function index(?string $storeHandle = null): CpScreenResponse
{
$store = $this->resolveStore($storeHandle);
@@ -138,6 +134,10 @@ public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResp
]);
}
+ // Condition classes no longer self-render; ConditionBuilderRenderer replaces the old getBuilderHtml()/builderHtml().
+ $orderConditionHtml = new ConditionBuilderRenderer($shippingMethod->getOrderCondition())->render();
+ $customerConditionHtml = new ConditionBuilderRenderer($shippingMethod->getCustomerCondition())->render();
+
return $this->storeManagementCpScreen($storeHandle, false)
->title($title)
->action('commerce/shipping-methods/save')
@@ -150,6 +150,8 @@ public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResp
'shippingRules' => $shippingRules,
'store' => $store,
'storeHandle' => $storeHandle,
+ 'orderConditionHtml' => $orderConditionHtml,
+ 'customerConditionHtml' => $customerConditionHtml,
]);
}
diff --git a/src/Http/Controllers/Settings/ShippingRulesController.php b/src/Http/Controllers/StoreManagement/ShippingRulesController.php
similarity index 92%
rename from src/Http/Controllers/Settings/ShippingRulesController.php
rename to src/Http/Controllers/StoreManagement/ShippingRulesController.php
index a05ed49f63..35390c7523 100644
--- a/src/Http/Controllers/Settings/ShippingRulesController.php
+++ b/src/Http/Controllers/StoreManagement/ShippingRulesController.php
@@ -2,17 +2,16 @@
declare(strict_types=1);
-namespace CraftCms\Commerce\Http\Controllers\Settings;
+namespace CraftCms\Commerce\Http\Controllers\StoreManagement;
use craft\helpers\Localization;
-use CraftCms\Cms\Http\RespondsWithFlash;
+use CraftCms\Cms\Condition\ConditionBuilderRenderer;
use CraftCms\Cms\Support\Facades\HtmlStack;
use CraftCms\Cms\Support\Facades\InputNamespace;
use CraftCms\Cms\Support\Facades\Template;
use CraftCms\Cms\Support\Json;
use CraftCms\Cms\Support\Money;
use CraftCms\Cms\View\TemplateMode;
-use CraftCms\Commerce\Http\Controllers\Concerns\HasStoreManagementScreen;
use CraftCms\Commerce\Shipping\Data\ShippingAddressZone;
use CraftCms\Commerce\Shipping\Data\ShippingRule;
use CraftCms\Commerce\Shipping\Data\ShippingRuleCategory;
@@ -26,11 +25,8 @@
use function CraftCms\Cms\pageTemplate;
use function CraftCms\Cms\t;
-readonly class ShippingRulesController
+readonly class ShippingRulesController extends BaseStoreManagementController
{
- use HasStoreManagementScreen;
- use RespondsWithFlash;
-
public function edit(?string $storeHandle = null, ?int $methodId = null, ?int $ruleId = null): string
{
$store = $this->resolveStore($storeHandle);
@@ -64,6 +60,10 @@ public function edit(?string $storeHandle = null, ?int $methodId = null, ?int $r
$title = $ruleId ? $shippingRule->name : t('Create a new shipping rule', category: 'commerce');
+ // Condition classes no longer self-render; ConditionBuilderRenderer replaces the old getBuilderHtml()/builderHtml().
+ $orderConditionHtml = new ConditionBuilderRenderer($shippingRule->getOrderCondition())->render();
+ $customerConditionHtml = new ConditionBuilderRenderer($shippingRule->getCustomerCondition())->render();
+
$shippingZones = app(ShippingZones::class)->getAllShippingZones($store->id)->all();
$shippingZoneOptions = [];
$shippingZoneOptions[] = t('Anywhere', category: 'commerce');
@@ -82,6 +82,8 @@ public function edit(?string $storeHandle = null, ?int $methodId = null, ?int $r
'ruleId' => $ruleId,
'shippingRule' => $shippingRule,
'shippingMethod' => $shippingMethod,
+ 'orderConditionHtml' => $orderConditionHtml,
+ 'customerConditionHtml' => $customerConditionHtml,
'newShippingZoneFields' => $newShippingZoneFields,
'newShippingZoneJs' => $newShippingZoneJs,
'title' => $title,
diff --git a/src/Http/Controllers/Settings/ShippingZonesController.php b/src/Http/Controllers/StoreManagement/ShippingZonesController.php
similarity index 93%
rename from src/Http/Controllers/Settings/ShippingZonesController.php
rename to src/Http/Controllers/StoreManagement/ShippingZonesController.php
index 0f4e4cd193..ab6184bc91 100644
--- a/src/Http/Controllers/Settings/ShippingZonesController.php
+++ b/src/Http/Controllers/StoreManagement/ShippingZonesController.php
@@ -2,9 +2,9 @@
declare(strict_types=1);
-namespace CraftCms\Commerce\Http\Controllers\Settings;
+namespace CraftCms\Commerce\Http\Controllers\StoreManagement;
-use CraftCms\Cms\Http\RespondsWithFlash;
+use CraftCms\Cms\Condition\ConditionBuilderRenderer;
use CraftCms\Cms\Http\Responses\CpScreenResponse;
use CraftCms\Cms\Support\Facades\HtmlStack;
use CraftCms\Cms\Support\Facades\I18N;
@@ -12,7 +12,6 @@
use CraftCms\Cms\Support\Json;
use CraftCms\Cms\View\Enums\Position;
use CraftCms\Commerce\Formula\Formulas;
-use CraftCms\Commerce\Http\Controllers\Concerns\HasStoreManagementScreen;
use CraftCms\Commerce\Shipping\Data\ShippingAddressZone;
use CraftCms\Commerce\Shipping\ShippingZones;
@@ -20,11 +19,8 @@
use Symfony\Component\HttpFoundation\Response;
use function CraftCms\Cms\t;
-readonly class ShippingZonesController
+readonly class ShippingZonesController extends BaseStoreManagementController
{
- use HasStoreManagementScreen;
- use RespondsWithFlash;
-
public function index(?string $storeHandle = null): CpScreenResponse
{
$store = $this->resolveStore($storeHandle);
@@ -84,6 +80,9 @@ public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResp
$condition->name = 'condition';
$condition->id = 'condition';
+ // Condition classes no longer self-render; ConditionBuilderRenderer replaces the old getBuilderHtml()/builderHtml().
+ $conditionHtml = new ConditionBuilderRenderer($condition)->render();
+
$metadata = [];
if ($shippingZone->id) {
$metadata = [
@@ -100,7 +99,7 @@ public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResp
->metaSidebarHtml(\craft\helpers\Cp::metadataHtml($metadata))
->contentTemplate('commerce/store-management/shipping/shippingzones/_edit', [
'shippingZone' => $shippingZone,
- 'condition' => $condition,
+ 'conditionHtml' => $conditionHtml,
'store' => $store,
]);
}
diff --git a/src/Http/Controllers/Settings/StoreManagementController.php b/src/Http/Controllers/StoreManagement/StoreManagementController.php
similarity index 96%
rename from src/Http/Controllers/Settings/StoreManagementController.php
rename to src/Http/Controllers/StoreManagement/StoreManagementController.php
index e3814bc9cc..7bdeeb3965 100644
--- a/src/Http/Controllers/Settings/StoreManagementController.php
+++ b/src/Http/Controllers/StoreManagement/StoreManagementController.php
@@ -2,18 +2,16 @@
declare(strict_types=1);
-namespace CraftCms\Commerce\Http\Controllers\Settings;
+namespace CraftCms\Commerce\Http\Controllers\StoreManagement;
use craft\helpers\Cp;
use CraftCms\Cms\Address\Elements\Address;
use CraftCms\Cms\Condition\ConditionBuilderRenderer;
-use CraftCms\Cms\Http\RespondsWithFlash;
use CraftCms\Cms\Http\Responses\CpScreenResponse;
use CraftCms\Cms\Support\Facades\Addresses;
use CraftCms\Cms\Support\Html;
use CraftCms\Commerce\Address\Conditions\ZoneAddressCondition;
use CraftCms\Commerce\Helpers\Cp as CommerceCp;
-use CraftCms\Commerce\Http\Controllers\Concerns\HasStoreManagementScreen;
use CraftCms\Commerce\Inventory\InventoryLocations;
use CraftCms\Commerce\Plugin;
use CraftCms\Commerce\Store\Stores;
@@ -25,11 +23,8 @@
use function CraftCms\Cms\currentUserElement;
use function CraftCms\Cms\t;
-readonly class StoreManagementController
+readonly class StoreManagementController extends BaseStoreManagementController
{
- use HasStoreManagementScreen;
- use RespondsWithFlash;
-
public function __construct(private Plugin $plugin)
{
}
@@ -93,6 +88,7 @@ public function edit(?string $storeHandle = null): Response|CpScreenResponse
$condition->mainTag = 'div';
$condition->name = 'marketAddressCondition';
$condition->id = 'marketAddressCondition';
+ // Condition classes no longer self-render; ConditionBuilderRenderer replaces the old getBuilderHtml()/builderHtml().
$marketAddressConditionFieldHtml = Cp::fieldHtml(new ConditionBuilderRenderer($condition)->render(), [
'label' => t('Order Address Condition'),
'instructions' => t('Only allow orders with addresses that match the following rules:'),
diff --git a/src/Http/Controllers/Settings/TaxCategoriesController.php b/src/Http/Controllers/StoreManagement/TaxCategoriesController.php
similarity index 97%
rename from src/Http/Controllers/Settings/TaxCategoriesController.php
rename to src/Http/Controllers/StoreManagement/TaxCategoriesController.php
index 8a98bb00a2..4005a96ccf 100644
--- a/src/Http/Controllers/Settings/TaxCategoriesController.php
+++ b/src/Http/Controllers/StoreManagement/TaxCategoriesController.php
@@ -2,10 +2,9 @@
declare(strict_types=1);
-namespace CraftCms\Commerce\Http\Controllers\Settings;
+namespace CraftCms\Commerce\Http\Controllers\StoreManagement;
use craft\helpers\Cp;
-use CraftCms\Cms\Http\RespondsWithFlash;
use CraftCms\Cms\Http\Responses\CpScreenResponse;
use CraftCms\Cms\Support\Arr;
use CraftCms\Cms\Support\Facades\HtmlStack;
@@ -13,7 +12,6 @@
use CraftCms\Cms\Support\Html as NewHtml;
use CraftCms\Cms\Support\Json;
use CraftCms\Cms\View\Enums\Position;
-use CraftCms\Commerce\Http\Controllers\Concerns\HasStoreManagementScreen;
use CraftCms\Commerce\Product\ProductType\ProductTypes;
use CraftCms\Commerce\Store\Data\Store;
use CraftCms\Commerce\Store\Stores;
@@ -26,11 +24,8 @@
use Symfony\Component\HttpFoundation\Response;
use function CraftCms\Cms\t;
-readonly class TaxCategoriesController
+readonly class TaxCategoriesController extends BaseStoreManagementController
{
- use HasStoreManagementScreen;
- use RespondsWithFlash;
-
public function index(?string $storeHandle = null): CpScreenResponse
{
$store = $this->resolveStore($storeHandle);
diff --git a/src/Http/Controllers/Settings/TaxRatesController.php b/src/Http/Controllers/StoreManagement/TaxRatesController.php
similarity index 97%
rename from src/Http/Controllers/Settings/TaxRatesController.php
rename to src/Http/Controllers/StoreManagement/TaxRatesController.php
index e057e7ac3c..ad84637008 100644
--- a/src/Http/Controllers/Settings/TaxRatesController.php
+++ b/src/Http/Controllers/StoreManagement/TaxRatesController.php
@@ -2,10 +2,9 @@
declare(strict_types=1);
-namespace CraftCms\Commerce\Http\Controllers\Settings;
+namespace CraftCms\Commerce\Http\Controllers\StoreManagement;
use craft\helpers\Cp;
-use CraftCms\Cms\Http\RespondsWithFlash;
use CraftCms\Cms\Http\Responses\CpScreenResponse;
use CraftCms\Cms\Support\Facades\HtmlStack;
use CraftCms\Cms\Support\Facades\I18N;
@@ -15,7 +14,6 @@
use CraftCms\Cms\View\Enums\Position;
use CraftCms\Commerce\Helpers\Cp as CommerceCp;
use CraftCms\Commerce\Helpers\Localization;
-use CraftCms\Commerce\Http\Controllers\Concerns\HasStoreManagementScreen;
use CraftCms\Commerce\Tax\Data\TaxRate;
use CraftCms\Commerce\Tax\Models\TaxRate as TaxRateRecord;
use CraftCms\Commerce\Tax\TaxCategories;
@@ -28,11 +26,8 @@
use Symfony\Component\HttpFoundation\Response;
use function CraftCms\Cms\t;
-readonly class TaxRatesController
+readonly class TaxRatesController extends BaseStoreManagementController
{
- use HasStoreManagementScreen;
- use RespondsWithFlash;
-
public function index(?string $storeHandle = null): CpScreenResponse
{
$store = $this->resolveStore($storeHandle);
diff --git a/src/Http/Controllers/Settings/TaxZonesController.php b/src/Http/Controllers/StoreManagement/TaxZonesController.php
similarity index 93%
rename from src/Http/Controllers/Settings/TaxZonesController.php
rename to src/Http/Controllers/StoreManagement/TaxZonesController.php
index cc033668aa..8d4e1c398e 100644
--- a/src/Http/Controllers/Settings/TaxZonesController.php
+++ b/src/Http/Controllers/StoreManagement/TaxZonesController.php
@@ -2,10 +2,10 @@
declare(strict_types=1);
-namespace CraftCms\Commerce\Http\Controllers\Settings;
+namespace CraftCms\Commerce\Http\Controllers\StoreManagement;
use craft\helpers\Cp;
-use CraftCms\Cms\Http\RespondsWithFlash;
+use CraftCms\Cms\Condition\ConditionBuilderRenderer;
use CraftCms\Cms\Http\Responses\CpScreenResponse;
use CraftCms\Cms\Support\Facades\HtmlStack;
use CraftCms\Cms\Support\Facades\I18N;
@@ -13,7 +13,6 @@
use CraftCms\Cms\Support\Json;
use CraftCms\Cms\View\Enums\Position;
use CraftCms\Commerce\Formula\Formulas;
-use CraftCms\Commerce\Http\Controllers\Concerns\HasStoreManagementScreen;
use CraftCms\Commerce\Tax\Data\TaxAddressZone;
use CraftCms\Commerce\Tax\TaxZones;
@@ -21,11 +20,8 @@
use Symfony\Component\HttpFoundation\Response;
use function CraftCms\Cms\t;
-readonly class TaxZonesController
+readonly class TaxZonesController extends BaseStoreManagementController
{
- use HasStoreManagementScreen;
- use RespondsWithFlash;
-
public function index(?string $storeHandle = null): CpScreenResponse
{
$store = $this->resolveStore($storeHandle);
@@ -94,6 +90,9 @@ public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResp
$condition->name = 'condition';
$condition->id = 'condition';
+ // Condition classes no longer self-render; ConditionBuilderRenderer replaces the old getBuilderHtml()/builderHtml().
+ $conditionHtml = new ConditionBuilderRenderer($condition)->render();
+
$metaSidebar = '';
if ($taxZone->id) {
$metaSidebar = Cp::metadataHtml([
@@ -112,7 +111,7 @@ public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResp
->contentTemplate('commerce/store-management/tax/taxzones/_edit', [
'taxZone' => $taxZone,
'store' => $store,
- 'condition' => $condition,
+ 'conditionHtml' => $conditionHtml,
]);
}
diff --git a/src/Order/Data/OrderStatus.php b/src/Order/Data/OrderStatus.php
index 3d61f5a011..4ab8cd3e21 100644
--- a/src/Order/Data/OrderStatus.php
+++ b/src/Order/Data/OrderStatus.php
@@ -7,6 +7,7 @@
use CraftCms\Cms\Component\Component;
use CraftCms\Cms\Component\Contracts\Chippable;
use CraftCms\Cms\Cp\Html\StatusHtml;
+use CraftCms\Cms\Support\Url;
use CraftCms\Commerce\Database\Table;
use CraftCms\Commerce\Email\Emails;
use CraftCms\Commerce\Order\Elements\Order;
@@ -89,7 +90,10 @@ public function extraFields(): array
public function getCpEditUrl(): string
{
- return $this->getStore()->getStoreSettingsUrl('orderstatuses/' . $this->id);
+ // getStoreSettingsUrl() points at commerce/store-management/..., but order statuses
+ // live under commerce/settings/orderstatuses/... — build that URL directly, matching
+ // the sibling LineItemStatus::getCpEditUrl().
+ return Url::cpUrl('commerce/settings/orderstatuses/' . $this->getStore()->handle . '/' . $this->id);
}
public function getEmailIds(): array
diff --git a/src/Store/Stores.php b/src/Store/Stores.php
index 2e1926783b..50f54a7072 100644
--- a/src/Store/Stores.php
+++ b/src/Store/Stores.php
@@ -300,9 +300,9 @@ public function handleChangedStore(ConfigEvent $event): void
$isNewStore = !$storeRecord->exists;
$storeRecord->uid = $storeUid;
- $storeRecord->name = $data['name'];
- $storeRecord->handle = $data['handle'];
- $storeRecord->primary = $data['primary'];
+ $storeRecord->name = $data['name'] ?? $storeRecord->name;
+ $storeRecord->handle = $data['handle'] ?? $storeRecord->handle;
+ $storeRecord->primary = $data['primary'] ?? $storeRecord->primary;
$storeRecord->autoSetNewCartAddresses = ($data['autoSetNewCartAddresses'] ?? false);
$storeRecord->autoSetCartShippingMethodOption = ($data['autoSetCartShippingMethodOption'] ?? false);
@@ -562,10 +562,12 @@ public function getAllSiteStores(): Collection
public function getSiteIdsAvailableForAssignmentToNewStores(): array
{
// Sites that are assigned to more than one store
+ // Note: COUNT(*) here, not COUNT(storeId) — the latter is an unquoted identifier inside
+ // raw SQL, which Postgres folds to lowercase (`storeid`), a column that doesn't exist.
$storeIds = DB::table(Table::SITESTORES)
->select('storeId')
->groupBy('storeId')
- ->havingRaw('COUNT(storeId) > 1')
+ ->havingRaw('COUNT(*) > 1')
->pluck('storeId');
return DB::table(Table::SITESTORES)
diff --git a/tests/Feature/Http/Controllers/Concerns/HasStoreManagementScreenTest.php b/tests/Feature/Http/Controllers/Concerns/HasStoreManagementScreenTest.php
index 380f203778..f5d01f72ad 100644
--- a/tests/Feature/Http/Controllers/Concerns/HasStoreManagementScreenTest.php
+++ b/tests/Feature/Http/Controllers/Concerns/HasStoreManagementScreenTest.php
@@ -3,7 +3,7 @@
declare(strict_types=1);
use CraftCms\Commerce\Http\Controllers\Settings\OrderStatusesController;
-use CraftCms\Commerce\Http\Controllers\Settings\ShippingZonesController;
+use CraftCms\Commerce\Http\Controllers\StoreManagement\ShippingZonesController;
use CraftCms\Commerce\Store\Stores;
use CraftCms\Commerce\Tests\Support\OrdersFixture;
use Illuminate\Support\Facades\App;