diff --git a/composer.json b/composer.json index f209490335..d6d47fac53 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "prefer-stable": true, "require": { "php": "^8.5", - "craftcms/cms": "6.x-dev", + "craftcms/cms": "dev-feature/6.x-form-component-compatibility-and-usage-updates", "dompdf/dompdf": "^3.1.6", "ibericode/vat": "^2.0", "iio/libmergepdf": "^4.0", diff --git a/lang/en/commerce.php b/lang/en/commerce.php index 5c24af4e39..1c9643e9d6 100644 --- a/lang/en/commerce.php +++ b/lang/en/commerce.php @@ -249,6 +249,7 @@ 'Couldn’t save shipping method.' => 'Couldn’t save shipping method.', 'Couldn’t save shipping rule.' => 'Couldn’t save shipping rule.', 'Couldn’t save shipping zone.' => 'Couldn’t save shipping zone.', + 'Couldn’t save store location address.' => 'Couldn’t save store location address.', 'Couldn’t save store.' => 'Couldn’t save store.', 'Couldn’t save subscription fields.' => 'Couldn’t save subscription fields.', 'Couldn’t save subscription plan.' => 'Couldn’t save subscription plan.', @@ -1034,6 +1035,7 @@ 'Shipping method saved.' => 'Shipping method saved.', 'Shipping methods and rules deleted.' => 'Shipping methods and rules deleted.', 'Shipping methods updated.' => 'Shipping methods updated.', + 'Shipping rule deleted.' => 'Shipping rule deleted.', 'Shipping rule saved.' => 'Shipping rule saved.', 'Shipping zone saved.' => 'Shipping zone saved.', 'Shipping' => 'Shipping', diff --git a/routes/actions.php b/routes/actions.php index 79ce7048ea..3a8c6e8ef7 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']); @@ -108,6 +109,7 @@ // this group must check both, matching that legacy compound check exactly. Route::middleware(['auth', 'can:accessPlugin-commerce', 'can:commerce-manageStoreSettings'])->group(function () { Route::post('store-management/save', [StoreManagementController::class, 'save']); + Route::post('store-management/render-form', [StoreManagementController::class, 'renderForm']); Route::middleware('can:commerce-managePaymentCurrencies')->group(function () { Route::post('payment-currencies/save', [PaymentCurrenciesController::class, 'save']); @@ -127,10 +129,12 @@ Route::post('shipping-rules/duplicate', [ShippingRulesController::class, 'duplicate']); Route::post('shipping-rules/reorder', [ShippingRulesController::class, 'reorder']); Route::post('shipping-rules/delete', [ShippingRulesController::class, 'delete']); + Route::post('shipping-rules/render-form', [ShippingRulesController::class, 'renderForm']); Route::post('shipping-categories/save', [ShippingCategoriesController::class, 'save']); Route::post('shipping-categories/delete', [ShippingCategoriesController::class, 'delete']); Route::post('shipping-categories/set-default-category', [ShippingCategoriesController::class, 'setDefaultCategory']); + Route::post('shipping-categories/render-form', [ShippingCategoriesController::class, 'renderForm']); }); Route::middleware('can:commerce-manageTaxes')->group(function () { @@ -145,6 +149,7 @@ Route::post('tax-rates/save', [TaxRatesController::class, 'save']); Route::post('tax-rates/delete', [TaxRatesController::class, 'delete']); Route::post('tax-rates/update-status', [TaxRatesController::class, 'updateStatus']); + Route::post('tax-rates/render-form', [TaxRatesController::class, 'renderForm']); }); Route::middleware('can:commerce-managePromotions')->group(function () { 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/CatalogPricing/CatalogPricing.php b/src/CatalogPricing/CatalogPricing.php index 07fcb246b3..729a241fe2 100644 --- a/src/CatalogPricing/CatalogPricing.php +++ b/src/CatalogPricing/CatalogPricing.php @@ -301,9 +301,16 @@ public function getCatalogPrices(int $storeId, ?CatalogPricingCondition $conditi public function getCatalogPricesPageInfo(int $storeId, ?CatalogPricingCondition $conditionBuilder = null, bool $includeBasePrices = true, ?string $searchText = null, int $limit = 100, int $offset = 0): array { + // getCountForPagination() wraps the query as-is and counts a column from its SELECT — + // but this query only ever selects price/promotionalPrice/salePrice aggregates, never + // purchasableId, so that column was never there to count. select()ing it first (like + // the pre-port Yii2 version did) replaces the aggregate select instead of layering atop + // it, giving a plain "one row per purchasableId" result to just count. $total = $this->buildCatalogPricesQuery($storeId, $conditionBuilder, $includeBasePrices, $searchText) + ->select(['purchasableId']) ->groupBy('purchasableId') - ->getCountForPagination(['purchasableId']); + ->get() + ->count(); return [ 'first' => $offset + 1, @@ -486,6 +493,10 @@ private function _normalizeIds(?array $ids): ?array */ public function createCatalogPricesQuery(?int $userId = null, int|string|null $storeId = null, bool $allPrices = false, ?CatalogPricingCondition $condition = null): \Illuminate\Database\Query\Builder { + // Quoted via the connection's own grammar, not embedded as bare SQL — the column is + // camelCase, and an unquoted raw fragment gets folded to lowercase by Postgres. + $isPromotionalPrice = DB::connection()->getQueryGrammar()->wrap('isPromotionalPrice'); + $query = DB::table(Table::CATALOG_PRICING . ' as cp') ->select([ DB::raw(Sql::decimalSql('MIN(CASE WHEN isPromotionalPrice = FALSE THEN price END)') . ' AS price'), @@ -508,10 +519,12 @@ public function createCatalogPricesQuery(?int $userId = null, int|string|null $s /** @var CatalogPricingCondition $condition */ $condition->modifyQuery($query); + // Plain where()s rather than whereRaw() — the column is camelCase, and an unquoted + // raw fragment gets folded to lowercase by Postgres, no longer matching it. $query->where(function($q) { - $q->whereNull('dateFrom')->orWhereRaw('dateFrom <= ?', [CraftDb::prepareDateForDb(new DateTime())]); + $q->whereNull('dateFrom')->orWhere('dateFrom', '<=', CraftDb::prepareDateForDb(new DateTime())); })->where(function($q) { - $q->whereNull('dateTo')->orWhereRaw('dateTo >= ?', [CraftDb::prepareDateForDb(new DateTime())]); + $q->whereNull('dateTo')->orWhere('dateTo', '>=', CraftDb::prepareDateForDb(new DateTime())); }); if (!$allPrices) { @@ -549,9 +562,9 @@ public function createCatalogPricingQuery(?int $userId = null, int|string|null $ $condition->modifyQuery($query); $query->where(function($q) { - $q->whereNull('dateFrom')->orWhereRaw('dateFrom <= ?', [CraftDb::prepareDateForDb(new DateTime())]); + $q->whereNull('dateFrom')->orWhere('dateFrom', '<=', CraftDb::prepareDateForDb(new DateTime())); })->where(function($q) { - $q->whereNull('dateTo')->orWhereRaw('dateTo >= ?', [CraftDb::prepareDateForDb(new DateTime())]); + $q->whereNull('dateTo')->orWhere('dateTo', '>=', CraftDb::prepareDateForDb(new DateTime())); })->orderBy('purchasableId')->orderBy('price'); if (!$allPrices) { diff --git a/src/Customer/Conditions/DiscountGroupConditionRule.php b/src/Customer/Conditions/DiscountGroupConditionRule.php index 742261e743..d143e88531 100644 --- a/src/Customer/Conditions/DiscountGroupConditionRule.php +++ b/src/Customer/Conditions/DiscountGroupConditionRule.php @@ -6,7 +6,7 @@ use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\User\Conditions\GroupConditionRule; -use Illuminate\Contracts\Database\Query\Builder; +use Illuminate\Database\Query\Builder; use Override; use RuntimeException; diff --git a/src/Http/Controllers/Concerns/HasStoreManagementScreen.php b/src/Http/Controllers/Concerns/HasStoreManagementScreen.php index 7ce1cdef27..3f203d44b2 100644 --- a/src/Http/Controllers/Concerns/HasStoreManagementScreen.php +++ b/src/Http/Controllers/Concerns/HasStoreManagementScreen.php @@ -19,7 +19,12 @@ * (shipping, tax, promotions, payment currencies, etc). Every controller that needs it resolves * its own `Store` from a `storeHandle` route segment — there's no framework-level route binding * for handle-scoped resources in cms-6 (confirmed: every handle-scoped core controller does the - * same manual resolve-and-404), so this stays a plain trait rather than a shared base class. + * same manual resolve-and-404) — so `resolveStore()` stays a plain method taking `$storeHandle` + * as a parameter, not something a shared base class could inject via its constructor. + * + * This still lives as a trait, mixed into {@see \CraftCms\Commerce\Http\Controllers\StoreManagement\BaseStoreManagementController} + * rather than inlined there directly, so the per-call resolution above isn't mistaken for + * something the base class's constructor could have done instead. */ trait HasStoreManagementScreen { diff --git a/src/Http/Controllers/Settings/BaseSettingsController.php b/src/Http/Controllers/Settings/BaseSettingsController.php new file mode 100644 index 0000000000..68693597fd --- /dev/null +++ b/src/Http/Controllers/Settings/BaseSettingsController.php @@ -0,0 +1,157 @@ +readOnly = !$generalConfig->allowAdminChanges; + } + + /** + * @return NavItem[] + */ + protected function subnav(): array + { + $path = request()->craftPath(); + + return [ + new NavItem() + ->label(t('General Settings', category: 'commerce')) + ->url(cp_url('commerce/settings/general')) + ->selected($path === 'commerce/settings/general'), + + new NavItem() + ->label(t('Stores & Sites', category: 'commerce')) + ->group(true) + ->subnav([ + new NavItem() + ->label(t('Stores', category: 'commerce')) + ->url(cp_url('commerce/settings/stores')) + ->selected($path === 'commerce/settings/stores'), + new NavItem() + ->label(t('Sites')) + ->url(cp_url('commerce/settings/sites')) + ->selected($path === 'commerce/settings/sites'), + ]), + + new NavItem() + ->label(t('Products', category: 'commerce')) + ->group(true) + ->subnav([ + new NavItem() + ->label(t('Product Types', category: 'commerce')) + ->url(cp_url('commerce/settings/producttypes')) + ->selected($path === 'commerce/settings/producttypes'), + ]), + + new NavItem() + ->label(t('Orders', category: 'commerce')) + ->group(true) + ->subnav([ + new NavItem() + ->label(t('Order Fields', category: 'commerce')) + ->url(cp_url('commerce/settings/ordersettings')) + ->selected($path === 'commerce/settings/ordersettings'), + new NavItem() + ->label(t('Order Statuses', category: 'commerce')) + ->url(cp_url('commerce/settings/orderstatuses')) + ->selected($path === 'commerce/settings/orderstatuses'), + new NavItem() + ->label(t('Line Item Statuses', category: 'commerce')) + ->url(cp_url('commerce/settings/lineitemstatuses')) + ->selected($path === 'commerce/settings/lineitemstatuses'), + ]), + + new NavItem() + ->label(t('PDFs & Emails', category: 'commerce')) + ->group(true) + ->subnav([ + new NavItem() + ->label(t('PDFs', category: 'commerce')) + ->url(cp_url('commerce/settings/pdfs')) + ->selected($path === 'commerce/settings/pdfs'), + new NavItem() + ->label(t('Emails', category: 'commerce')) + ->url(cp_url('commerce/settings/emails')) + ->selected($path === 'commerce/settings/emails'), + ]), + + new NavItem() + ->label(t('Payments', category: 'commerce')) + ->group(true) + ->subnav([ + new NavItem() + ->label(t('Gateways', category: 'commerce')) + ->url(cp_url('commerce/settings/gateways')) + ->selected($path === 'commerce/settings/gateways'), + ]), + + new NavItem() + ->label(t('Transfers', category: 'commerce')) + ->group(true) + ->subnav([ + new NavItem() + ->label(t('Transfer Fields', category: 'commerce')) + ->url(cp_url('commerce/settings/transfers')) + ->selected($path === 'commerce/settings/transfers'), + ]), + ]; + } + + /** + * Returns this controller's own section crumb (e.g. "Emails"). {@see self::crumbs()} + * decides whether it actually links, based on whether anything follows it. + * + * @return array{label: string, href: string} + */ + abstract protected function getSectionCrumb(): array; + + /** + * Builds "Settings /
[ / ...$trail]". Pass one entry per crumb + * beyond the section (usually zero, for an index; one, for a record being + * edited). Whichever crumb ends up last never links, since it's the page + * already showing. + * + * @param array{label: string, url?: ?string} ...$trail + * @return list> + */ + final protected function crumbs(array ...$trail): array + { + $crumbs = [ + ['label' => t('Settings'), 'href' => cp_url('commerce/settings')], + $this->getSectionCrumb(), + ...array_map(fn(array $crumb) => ['label' => $crumb['label'], 'href' => $crumb['url'] ?? null], $trail), + ]; + + $crumbs[array_key_last($crumbs)]['href'] = null; + + return $crumbs; + } + + protected function cpScreenResponse(): CpScreenResponse + { + return new CpScreenResponse() + ->subnav($this->subnav()); + } +} diff --git a/src/Http/Controllers/Settings/EmailsController.php b/src/Http/Controllers/Settings/EmailsController.php index 069429b93b..96da69cd93 100644 --- a/src/Http/Controllers/Settings/EmailsController.php +++ b/src/Http/Controllers/Settings/EmailsController.php @@ -5,52 +5,115 @@ namespace CraftCms\Commerce\Http\Controllers\Settings; use craft\helpers\App; -use CraftCms\Cms\Config\GeneralConfig; -use CraftCms\Cms\Http\RespondsWithFlash; +use CraftCms\Cms\Cp\SelectOptions; +use CraftCms\Cms\Form\Controls\Choice; +use CraftCms\Cms\Form\Controls\Combobox; +use CraftCms\Cms\Form\Controls\Lightswitch; +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\Table; use CraftCms\Cms\Http\Responses\CpScreenResponse; -use CraftCms\Cms\Site\Data\Site; -use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\Sites; -use CraftCms\Cms\View\TemplateMode; +use CraftCms\Cms\Support\Html; +use CraftCms\Cms\Support\Url; use CraftCms\Commerce\Email\Data\Email; use CraftCms\Commerce\Email\Emails; use CraftCms\Commerce\Email\Models\Email as EmailRecord; use CraftCms\Commerce\Helpers\Locale as LocaleHelper; -use CraftCms\Commerce\Pdf\Data\Pdf; use CraftCms\Commerce\Pdf\Pdfs; use CraftCms\Commerce\Store\Data\Store; - use CraftCms\Commerce\Store\Stores; + +use Illuminate\Http\JsonResponse; 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 EmailsController +class EmailsController extends BaseSettingsController { - use RespondsWithFlash; - - private bool $readOnly; - - public function __construct(GeneralConfig $generalConfig) + protected function getSectionCrumb(): array { - $this->readOnly = !$generalConfig->allowAdminChanges; + return ['label' => t('Emails', category: 'commerce'), 'href' => cp_url('commerce/settings/emails')]; } - public function index(): string + public function index(): CpScreenResponse { - $emails = []; $stores = app(Stores::class)->getAllStores(); + $isMultiStore = $stores->count() > 1; + + // Every store's table can plant its own create action in the page's shared actions + // slot, so with more than one store, only the first store's table gets one — a single + // combined "New email" menu covering every store, rather than one button apiece. + $createMenuItems = $this->readOnly ? [] : $stores->map(fn(Store $store) => [ + 'label' => $store->name, + 'url' => cp_url("commerce/settings/emails/{$store->handle}/new"), + ])->all(); + $createMenuAssigned = false; - $stores->each(function(Store $store) use (&$emails) { - $emails[$store->handle] = app(Emails::class)->getAllEmails($store->id); + $nodes = []; + $stores->each(function(Store $store) use (&$nodes, $isMultiStore, $createMenuItems, &$createMenuAssigned) { + if ($isMultiStore) { + $nodes[] = Heading::make("{$store->handle}-heading", $store->name); + } + + $rows = app(Emails::class)->getAllEmails($store->id) + ->map(function(Email $email) { + $to = $email->recipientType === EmailRecord::TYPE_CUSTOM + ? $email->getTo(false) + : t('Customer', category: 'commerce'); + $previewUrl = Url::actionUrl('commerce/email-preview/render', ['email' => "{$email->id}:{$email->storeId}"]); + + return [ + 'id' => $email->id, + 'name' => ['label' => t($email->name, category: 'site'), 'url' => $email->getCpEditUrl()], + 'subject' => t($email->subject, category: 'site'), + 'to' => $to, + 'bcc' => $email->getBcc(false), + 'template' => $email->templatePath ? ['html' => Html::tag('code', Html::encode($email->templatePath))] : '', + // Opens in a new tab, same as the old admin table's preview button — the + // structured link shapes don't support that, so this is a hand-built anchor. + 'preview' => ['html' => Html::a(t('Preview', category: 'commerce'), $previewUrl, [ + 'class' => 'btn small', + 'target' => '_blank', + 'rel' => 'noopener', + ])], + ]; + }) + ->all(); + + $nodes[] = Table::make("{$store->handle}-emails") + ->columns([ + ['key' => 'name', 'label' => t('Name')], + ['key' => 'subject', 'label' => t('Subject', category: 'commerce')], + ['key' => 'to', 'label' => t('To', category: 'commerce')], + ['key' => 'bcc', 'label' => t('Bcc', category: 'commerce')], + ['key' => 'template', 'label' => t('Template Path', category: 'commerce')], + ['key' => 'preview', 'label' => t('Preview', category: 'commerce')], + ]) + ->rows($rows) + ->emptyMessage(t('No emails exist yet.', category: 'commerce')) + ->when(!$createMenuAssigned && $createMenuItems, function(Table $table) use ($createMenuItems, &$createMenuAssigned) { + $table->createActionMenu(t('New email', category: 'commerce'), $createMenuItems); + $createMenuAssigned = true; + }) + ->when(!$this->readOnly, fn(Table $table) => $table + ->deletable(action([self::class, 'delete']))); }); - return pageTemplate('commerce/settings/emails/index', [ - 'stores' => $stores->all(), - 'emails' => $emails, - 'readOnly' => $this->readOnly, - ], TemplateMode::Cp); + $title = t('Emails', category: 'commerce'); + + return $this->cpScreenResponse() + ->title($title) + ->crumbs($this->crumbs()) + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve(Form::make($nodes), new FormContext()), + ]); } public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse @@ -68,48 +131,162 @@ public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResp $title = $email->id ? $email->name : t('Create a new email', category: 'commerce'); - $pdfs = app(Pdfs::class)->getAllPdfs($email->storeId); - $pdfList = ['' => t('Do not attach a PDF to this email', category: 'commerce')]; - $pdfList = Arr::merge($pdfList, $pdfs->mapWithKeys(fn(Pdf $pdf) => [$pdf->id => $pdf->name])->all()); - $senderAddressPlaceholder = App::mailSettings()->fromEmail; - $senderNamePlaceholder = App::mailSettings()->fromName; - - $emailLanguageOptions = [ - EmailRecord::LOCALE_ORDER_LANGUAGE => t('The language the order was made in.', category: 'commerce'), + $values = [ + 'storeId' => $store->id, + 'id' => $email->id, + 'name' => $email->name, + 'senderAddress' => $email->getSenderAddress(false), + 'senderName' => $email->getSenderName(false), + 'subject' => $email->subject, + 'replyTo' => $email->replyTo, + 'recipientType' => $email->recipientType, + 'to' => $email->getTo(false), + 'bcc' => $email->getBcc(false), + 'cc' => $email->getCc(false), + 'templatePath' => $email->templatePath, + 'plainTextTemplatePath' => $email->plainTextTemplatePath, + 'pdfId' => $email->pdfId, + 'language' => $email->language, + 'renderSiteId' => $email->renderSiteId, + 'enabled' => $email->enabled, ]; - $emailLanguageOptions = array_merge($emailLanguageOptions, LocaleHelper::getSiteAndOtherLanguages()); - - $emailRenderSiteOptions = [ - '' => t('The site the order was made in.', category: 'commerce'), - ['optgroup' => t('Sites', category: 'commerce')], - ] + collect(Sites::getAllSites())->mapWithKeys(fn(Site $site) => [$site->id => $site->name])->all(); + $form = $this->formResolver->resolve($this->buildForm($values), new FormContext( + values: $values, + mode: $this->generalConfig->allowAdminChanges ? ControlMode::Editable : ControlMode::ReadOnly, + refreshable: !$this->readOnly, + )); - return new CpScreenResponse() + 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('Emails', category: 'commerce'), 'url' => 'commerce/settings/emails'], - ]) - ->selectedSubnavItem('settings') + ->crumbs($email->id ? $this->crumbs(['label' => $title]) : $this->crumbs()) ->action('commerce/emails/save') ->redirectUrl('commerce/settings/emails') - ->contentTemplate('commerce/settings/emails/_edit', [ - 'email' => $email, - 'pdfList' => $pdfList, - 'senderAddressPlaceholder' => $senderAddressPlaceholder, - 'senderNamePlaceholder' => $senderNamePlaceholder, - 'emailLanguageOptions' => $emailLanguageOptions, - 'emailRenderSiteOptions' => $emailRenderSiteOptions, - 'readOnly' => $this->readOnly, + ->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'save']), + ], + 'refreshUrl' => $this->readOnly ? null : action([self::class, 'renderForm']), ]); } + /** + * Re-resolves the {@see edit()} Form tree for the values currently in progress on the + * client, so the Recipient toggle can show or hide the Custom Recipient field without a + * full page reload. + */ + public function renderForm(Request $request): JsonResponse + { + // Validate for shape only — Request::validate() returns just the ruled subset, which + // would silently strip every field but the ones named here back out of `values`. + // Reading the raw input keeps the full posted values intact for buildForm()'s branching. + $request->validate([ + 'values' => ['required', 'array'], + 'scope' => ['present', 'array', 'size:0'], + ]); + + $values = $request->input('values'); + + $form = $this->formResolver->resolve($this->buildForm($values), new FormContext( + values: $values, + mode: ControlMode::Editable, + refreshable: true, + )); + + return new JsonResponse(['form' => $form]); + } + + /** @param array $values */ + private function buildForm(array $values): Form + { + $storeId = (int)($values['storeId'] ?? 0); + $store = app(Stores::class)->getStoreById($storeId) ?? app(Stores::class)->getPrimaryStore(); + + $pdfOptions = [['label' => t('Do not attach a PDF to this email', category: 'commerce'), 'value' => '']]; + foreach (app(Pdfs::class)->getAllPdfs($store->id) as $pdf) { + $pdfOptions[] = ['label' => $pdf->name, 'value' => $pdf->id]; + } + + // The old select field grouped these under "Site Languages"/"Other Languages" optgroup + // headers; Choice has no optgroup concept, so this flattens them into one list — every + // option still selectable, just without the section headers. + $languageOptions = collect([EmailRecord::LOCALE_ORDER_LANGUAGE => t('The language the order was made in.', category: 'commerce')]) + ->merge(LocaleHelper::getSiteAndOtherLanguages()) + ->reject(fn($label) => is_array($label)) + ->map(fn($label, $value) => ['label' => $label, 'value' => $value]) + ->values() + ->all(); + + $siteOptions = [['label' => t('The site the order was made in.', category: 'commerce'), 'value' => '']]; + foreach (Sites::getAllSites() as $site) { + $siteOptions[] = ['label' => $site->name, 'value' => $site->id]; + } + + $envTriggers = SelectOptions::getEnvTextExpanderTriggers(); + $isCustomRecipient = ($values['recipientType'] ?? EmailRecord::TYPE_CUSTOMER) === EmailRecord::TYPE_CUSTOM; + + $formNodes = [ + HiddenField::make('storeId'), + ]; + + if (!empty($values['id'])) { + $formNodes[] = HiddenField::make('id'); + } + + $formNodes[] = Field::make(t('Name', category: 'commerce'), Text::make('name')->autofocus()) + ->instructions(t('What this email will be called in the control panel.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Status Email Address', category: 'commerce'), Text::make('senderAddress') + ->placeholder(App::mailSettings()->fromEmail) + ->textExpanderTriggers($envTriggers)) + ->instructions(t('The email address that order status emails are sent from. Leave blank to use the System Email Address defined in Craft’s General Settings.', category: 'commerce')); + $formNodes[] = Field::make(t('From Name', category: 'commerce'), Text::make('senderName') + ->placeholder(App::mailSettings()->fromName) + ->textExpanderTriggers($envTriggers)) + ->instructions(t('The “From” name that will be used when sending order status emails. Leave blank to use the Sender Name defined in Craft’s General Settings.', category: 'commerce')); + $formNodes[] = Field::make(t('Email Subject', category: 'commerce'), Text::make('subject')) + ->instructions(t('The subject line of the email. Twig code can be used here.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Reply To', category: 'commerce'), Text::make('replyTo')) + ->instructions(t('The reply to email address. Leave blank for normal reply to of email sender. Twig code can be used here.', category: 'commerce')); + $formNodes[] = Field::make(t('Recipient', category: 'commerce'), Choice::make('recipientType')->options([ + ['label' => t('Send to the customer', category: 'commerce'), 'value' => EmailRecord::TYPE_CUSTOMER], + ['label' => t('Send to custom recipient', category: 'commerce'), 'value' => EmailRecord::TYPE_CUSTOM], + ])->reactive()) + ->instructions(t('The recipient of the email. Twig code can be used here.', category: 'commerce')) + ->required(); + // The old template combined this with Recipient as one compound field, showing/hiding + // this half inline based on the select above; here it's its own Field, toggled visible + // via the Form's refreshable round trip instead. + $formNodes[] = Field::make(t('Recipient', category: 'commerce'), Text::make('to')->textExpanderTriggers($envTriggers)) + ->visible($isCustomRecipient); + $formNodes[] = Field::make(t('BCC’d Recipient', category: 'commerce'), Text::make('bcc')->textExpanderTriggers($envTriggers)) + ->instructions(t('Additional recipients that should receive this email. Twig code can be used here.', category: 'commerce')); + $formNodes[] = Field::make(t('CC’d Recipient', category: 'commerce'), Text::make('cc')->textExpanderTriggers($envTriggers)) + ->instructions(t('Additional recipients that should receive this email. Twig code can be used here.', category: 'commerce')); + $formNodes[] = Field::make(t('HTML Email Template Path', category: 'commerce'), Combobox::make('templatePath')->options(SelectOptions::getTemplateSuggestions())) + ->instructions(t('The template to be used for HTML emails.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Plain Text Email Template Path', category: 'commerce'), Text::make('plainTextTemplatePath')) + ->instructions(t('The template to be used for plain text emails. Twig code can be used here.', category: 'commerce')); + $formNodes[] = Field::make(t('PDF Attachment', category: 'commerce'), Choice::make('pdfId')->options($pdfOptions)) + ->instructions(t('The PDF to attach to this email.', category: 'commerce')); + $formNodes[] = Field::make(t('Language', category: 'commerce'), Choice::make('language')->options($languageOptions)) + ->instructions(t('The language to be used when this email is rendered.', category: 'commerce')); + $formNodes[] = Field::make(t('Site', category: 'app'), Choice::make('renderSiteId')->options($siteOptions)) + ->instructions(t('The site to be used when this email is rendered.', category: 'commerce')); + $formNodes[] = Field::make(t('Enabled?', category: 'commerce'), Lightswitch::make('enabled')) + ->instructions(t('If disabled, this email will not send.', category: 'commerce')); + + return Form::make($formNodes); + } + public function save(Request $request): Response { $emailsService = app(Emails::class); - $emailId = $request->input('emailId') ? (int)$request->input('emailId') : null; + $emailId = $request->input('id') ? (int)$request->input('id') : null; $storeId = $request->input('storeId'); abort_if(!$storeId, 400, "Invalid store ID: $storeId"); $storeId = (int)$storeId; @@ -142,7 +319,7 @@ public function save(Request $request): Response $email->setSenderName($request->input('senderName')); if (!$emailsService->saveEmail($email)) { - return $this->asModelFailure($email, t('Couldn\'t save email.', category: 'commerce'), 'email'); + return $this->asModelFailure($email, t('Couldn’t save email.', category: 'commerce'), 'email'); } return $this->asModelSuccess($email, t('Email saved.', category: 'commerce'), 'email'); @@ -156,7 +333,7 @@ public function delete(Request $request): Response abort_if(!$id, 400, 'Missing email id'); if (!app(Emails::class)->deleteEmailById((int)$id)) { - return $this->asFailure(t('Couldn\'t delete email.', category: 'commerce')); + return $this->asFailure(t('Couldn’t delete email.', category: 'commerce')); } return $this->asSuccess(); diff --git a/src/Http/Controllers/Settings/GatewaysController.php b/src/Http/Controllers/Settings/GatewaysController.php index fda2d75c67..21e9ca6a94 100644 --- a/src/Http/Controllers/Settings/GatewaysController.php +++ b/src/Http/Controllers/Settings/GatewaysController.php @@ -4,68 +4,124 @@ namespace CraftCms\Commerce\Http\Controllers\Settings; -use CraftCms\Cms\Condition\ConditionBuilderRenderer; -use CraftCms\Cms\Config\GeneralConfig; -use CraftCms\Cms\Http\RespondsWithFlash; +use CraftCms\Cms\Cp\FormFields; +use CraftCms\Cms\Cp\SelectOptions; +use CraftCms\Cms\Form\Controls\Choice; +use CraftCms\Cms\Form\Controls\Combobox; +use CraftCms\Cms\Form\Controls\ConditionBuilder; +use CraftCms\Cms\Form\Controls\Handle; +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\Group; +use CraftCms\Cms\Form\Nodes\HiddenField; +use CraftCms\Cms\Form\Nodes\Table; +use CraftCms\Cms\Http\Responses\CpScreenResponse; use CraftCms\Cms\Support\Html; -use CraftCms\Cms\View\TemplateMode; -use CraftCms\Commerce\Database\Table; +use CraftCms\Cms\Support\Url; +use CraftCms\Commerce\Address\Conditions\GatewayAddressCondition; +use CraftCms\Commerce\Database\Table as DbTable; +use CraftCms\Commerce\Order\Conditions\GatewayOrderCondition; +use CraftCms\Commerce\Payment\Gateway\Contracts\GatewayInterface; use CraftCms\Commerce\Payment\Gateway\Gateways; use CraftCms\Commerce\Payment\Gateway\Types\Dummy; use CraftCms\Commerce\Payment\Gateway\Types\MissingGateway; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; - use Symfony\Component\HttpFoundation\Response; -use function CraftCms\Cms\pageTemplate; +use function CraftCms\Cms\cp_url; use function CraftCms\Cms\t; -readonly class GatewaysController +class GatewaysController extends BaseSettingsController { - use RespondsWithFlash; - - private bool $readOnly; - - public function __construct(GeneralConfig $generalConfig) + protected function getSectionCrumb(): array { - $this->readOnly = !$generalConfig->allowAdminChanges; + return ['label' => t('Gateways', category: 'commerce'), 'href' => cp_url('commerce/settings/gateways')]; } - public function index(): string + public function index(): CpScreenResponse { - $gateways = app(Gateways::class)->getAllGateways(); - $archivedGateways = app(Gateways::class)->getAllArchivedGateways(); + $gatewayService = app(Gateways::class); + $gateways = $gatewayService->getAllGateways(); + $archivedGateways = $gatewayService->getAllArchivedGateways(); + + $nameCell = fn(GatewayInterface $gateway) => ['html' => Html::a( + Html::encode(t($gateway->name, category: 'site')), + $gateway->getCpEditUrl(), + ['class' => 'cell-bold'], + )]; + $typeCell = fn(GatewayInterface $gateway) => ['html' => $gateway instanceof MissingGateway + ? Html::tag('span', Html::encode($gateway->expectedType), ['class' => 'error']) + : Html::encode($gateway::displayName()), ]; + + $rows = $gateways->map(fn(GatewayInterface $gateway) => [ + 'id' => $gateway->id, + 'name' => $nameCell($gateway), + 'handle' => ['html' => FormFields::copytextHtml(['value' => $gateway->handle, 'monospace' => true])], + 'type' => $typeCell($gateway), + 'customerEnabled' => $gateway->getIsFrontendEnabled() ? ['icon' => 'check', 'label' => t('Yes')] : '', + ])->values()->all(); + + $nodes = [ + Table::make('gateways') + ->columns([ + ['key' => 'id', 'label' => t('ID')], + ['key' => 'name', 'label' => t('Name')], + ['key' => 'handle', 'label' => t('Handle')], + ['key' => 'type', 'label' => t('Type', category: 'commerce')], + ['key' => 'customerEnabled', 'label' => t('Customer Enabled?', category: 'commerce')], + ]) + ->rows($rows) + ->emptyMessage(t('No gateways exist yet.', category: 'commerce')) + ->when(!$this->readOnly, fn(Table $table) => $table + ->createAction(t('New gateway', category: 'commerce'), cp_url('commerce/settings/gateways/new')) + ->reorderable(action([self::class, 'reorder'])) + ->deletable(action([self::class, 'archive']))), + ]; - if (!empty($archivedGateways)) { - $gatewayIdsWithTransactions = DB::table(Table::TRANSACTIONS) + if ($archivedGateways !== []) { + $gatewayIdsWithTransactions = DB::table(DbTable::TRANSACTIONS) ->select('gatewayId') ->groupBy('gatewayId') ->pluck('gatewayId') ->all(); - foreach ($archivedGateways as &$gateway) { - $missing = $gateway instanceof MissingGateway; - $gateway = [ - 'id' => $gateway->id, - 'title' => Html::encode(t($gateway->name, category: 'site')), - 'handle' => Html::encode($gateway->handle), - 'type' => [ - 'missing' => $missing, - 'name' => Html::encode($missing ? $gateway->expectedType : $gateway->displayName()), - ], - 'hasTransactions' => in_array($gateway->id, $gatewayIdsWithTransactions), - ]; - } + $archivedRows = array_values(array_map(fn(GatewayInterface $gateway) => [ + 'id' => $gateway->id, + 'name' => $nameCell($gateway), + 'handle' => ['html' => FormFields::copytextHtml(['value' => $gateway->handle, 'monospace' => true])], + 'type' => $typeCell($gateway), + 'hasTransactions' => in_array($gateway->id, $gatewayIdsWithTransactions, true) ? ['icon' => 'check', 'label' => t('Yes')] : '', + ], $archivedGateways)); + + $nodes[] = Group::make('archived-gateways', [ + Table::make('archived-gateways-table') + ->columns([ + ['key' => 'id', 'label' => t('ID')], + ['key' => 'name', 'label' => t('Name')], + ['key' => 'handle', 'label' => t('Handle')], + ['key' => 'type', 'label' => t('Type', category: 'commerce')], + ['key' => 'hasTransactions', 'label' => t('Has Transactions?', category: 'commerce')], + ]) + ->rows($archivedRows), + ])->label(t('Show archived gateways', category: 'commerce'))->collapsible(); } - return pageTemplate('commerce/settings/gateways/index', [ - 'gateways' => $gateways, - 'archivedGateways' => array_values($archivedGateways), - 'readOnly' => $this->readOnly, - ], TemplateMode::Cp); + $title = t('Gateways', category: 'commerce'); + + return $this->cpScreenResponse() + ->title($title) + ->crumbs($this->crumbs()) + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve(Form::make($nodes), new FormContext()), + ]); } - public function edit(?int $id = null): string + public function edit(?int $id = null): CpScreenResponse { $gatewayService = app(Gateways::class); @@ -76,40 +132,200 @@ public function edit(?int $id = null): string $gateway = $gatewayService->createGateway(['type' => Dummy::class]); } + $title = $gateway->id ? $gateway->name : t('Create a new gateway', category: 'commerce'); + + // The Combobox only recognizes its own '1'/'0' option values (or an env var + // reference) — a literal bool default (unparsed) needs stringifying first, or it + // shows as free-typed text instead of resolving to the matching Yes/No option. + $isFrontendEnabled = $gateway->getIsFrontendEnabled(false); + if (is_bool($isFrontendEnabled)) { + $isFrontendEnabled = $isFrontendEnabled ? '1' : '0'; + } + + $values = [ + 'id' => $gateway->id, + 'name' => $gateway->name, + 'handle' => $gateway->handle, + 'type' => $gateway::class, + 'paymentType' => $gateway->paymentType, + 'isFrontendEnabled' => $isFrontendEnabled, + 'orderCondition' => $gateway->getOrderCondition()->getConfig(), + 'billingAddressCondition' => $gateway->getBillingAddressCondition()->getConfig(), + 'shippingAddressCondition' => $gateway->getShippingAddressCondition()->getConfig(), + ...$gateway->getSettings(), + ]; + + $form = $this->formResolver->resolve($this->buildForm($values), new FormContext( + values: $values, + mode: $this->generalConfig->allowAdminChanges ? ControlMode::Editable : ControlMode::ReadOnly, + refreshable: !$this->readOnly, + )); + + return $this->cpScreenResponse() + ->title($title) + ->crumbs($gateway->id ? $this->crumbs(['label' => $title]) : $this->crumbs()) + ->action('commerce/gateways/save') + ->redirectUrl('commerce/settings/gateways') + ->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'save']), + ], + 'refreshUrl' => $this->readOnly ? null : action([self::class, 'renderForm']), + ]); + } + + /** + * Re-resolves the {@see edit()} Form tree for the values currently in progress on the + * client, so switching the Gateway type can swap in that type's own settings fields + * without a full page reload. + */ + public function renderForm(Request $request): JsonResponse + { + // Validate for shape only — Request::validate() returns just the ruled subset, which + // would silently strip every field but the ones named here back out of `values`. + // Reading the raw input keeps the full posted values intact for buildForm()'s branching. + $request->validate([ + 'values' => ['required', 'array'], + 'scope' => ['present', 'array', 'size:0'], + ]); + + $values = $request->input('values'); + + $form = $this->formResolver->resolve($this->buildForm($values), new FormContext( + values: $values, + mode: ControlMode::Editable, + refreshable: true, + )); + + return new JsonResponse(['form' => $form]); + } + + /** + * @param array $values Passed by reference so a stale `paymentType` + * left over from a since-changed Gateway type — no longer one of its options — can be + * corrected here and reflected back in the FormContext both callers resolve against. + */ + private function buildForm(array &$values): Form + { + $gatewayService = app(Gateways::class); + $type = $values['type'] ?? Dummy::class; + /** @var string[] $allGatewayTypes */ $allGatewayTypes = $gatewayService->getAllGatewayTypes(); - // Make sure the selected gateway class is in there - if (!in_array($gateway::class, $allGatewayTypes, true)) { - $allGatewayTypes[] = $gateway::class; + // Make sure the selected gateway class is in there, even if it's no longer selectable + // (e.g. a plugin gateway type that's since been superseded). + if (!in_array($type, $allGatewayTypes, true)) { + $allGatewayTypes[] = $type; } - $gatewayInstances = []; $gatewayOptions = []; - foreach ($allGatewayTypes as $class) { - if ($class === $gateway::class || $class::isSelectable()) { - $gatewayInstances[$class] = $gatewayService->createGateway($class); - - $gatewayOptions[] = [ - 'value' => $class, - 'label' => $class::displayName(), - ]; + if ($class === $type || $class::isSelectable()) { + $gatewayOptions[] = ['value' => $class, 'label' => $class::displayName()]; } } - return pageTemplate('commerce/settings/gateways/_edit', [ - 'id' => $id, - 'gateway' => $gateway, - 'gatewayTypes' => $allGatewayTypes, - 'gatewayInstances' => $gatewayInstances, - 'gatewayOptions' => $gatewayOptions, - 'title' => $gateway->id ? $gateway->name : t('Create a new gateway', category: 'commerce'), - 'readOnly' => $this->readOnly, - 'orderConditionHtml' => new ConditionBuilderRenderer($gateway->getOrderCondition(), !$this->readOnly)->render(), - 'billingAddressConditionHtml' => new ConditionBuilderRenderer($gateway->getBillingAddressCondition(), !$this->readOnly)->render(), - 'shippingAddressConditionHtml' => new ConditionBuilderRenderer($gateway->getShippingAddressCondition(), !$this->readOnly)->render(), - ], TemplateMode::Cp); + // Rebuild a live instance of the currently selected type, seeded with whatever's been + // posted so far — mirrors save()'s own config-building, so switching types (or a + // reactive round trip mid-edit) always reflects what's actually in the form right now. + $bareGateway = $gatewayService->createGateway($type); + // getSettings() (rather than settingsAttributes()) since a gateway type may back its + // settings with private properties exposed only through a custom getSettings() + // override — Manual's onlyAllowForZeroPriceOrders being a case in point. + $settingsKeys = array_keys($bareGateway->getSettings()); + + $gateway = $gatewayService->createGateway([ + 'type' => $type, + 'id' => $values['id'] ?? null, + 'name' => $values['name'] ?? null, + 'handle' => $values['handle'] ?? null, + 'paymentType' => $values['paymentType'] ?? null, + 'isFrontendEnabled' => $values['isFrontendEnabled'] ?? null, + 'settings' => Arr::only($values, $settingsKeys), + ]); + + $handle = Handle::make('handle'); + if (empty($values['id'])) { + $handle->source('name'); + } + + $formNodes = []; + + if (!empty($values['id'])) { + $formNodes[] = HiddenField::make('id'); + } + + $formNodes[] = Field::make(t('Name', category: 'commerce'), Text::make('name')->autofocus()) + ->instructions(t('What this gateway will be called in the control panel.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Handle', category: 'commerce'), $handle) + ->instructions(t('How you’ll refer to this gateway in the templates.', category: 'commerce')) + ->required(); + + if ($gateway->id && $gateway->supportsWebhooks()) { + $formNodes[] = Field::make(t('Webhook URL', category: 'commerce'), Text::make('webhookUrl') + ->mode(ControlMode::ReadOnly) + ->value(Url::siteUrl('commerce/webhooks/process-webhook/gateway/' . $gateway->id))) + ->instructions(t('The webhook URL for this gateway.', category: 'commerce')); + } + + $formNodes[] = Field::make(t('Gateway', category: 'commerce'), Choice::make('type')->options($gatewayOptions)->reactive()) + ->warning(!empty($values['id']) ? t('Changing this value may affect your ability to refund existing transactions.', category: 'commerce') : null) + ->required(); + + $paymentTypeOptions = collect($gateway->getPaymentTypeOptions())->map(fn($label, $value) => ['label' => $label, 'value' => $value])->values(); + + // Switching Gateway type can leave a paymentType selected that the new type doesn't + // offer (e.g. Manual only offers "authorize"); fall back to that type's first option + // rather than silently submitting a value it never presented as a choice. + if (!$paymentTypeOptions->contains('value', $values['paymentType'] ?? null)) { + $values['paymentType'] = $paymentTypeOptions->first()['value'] ?? null; + } + + $formNodes[] = Field::make(t('Credit Card Payment Type', category: 'commerce'), Choice::make('paymentType')->options($paymentTypeOptions->all())) + ->instructions(t('If set to Authorize Only, you will need to manually capture payments before the funds will be transferred to your account. The Gateway needs to support the selected option.', category: 'commerce')) + ->required(); + + $settingsForm = $gateway->settingsForm(new FormContext( + values: $values, + mode: ControlMode::Editable, + )); + if ($settingsForm !== null) { + array_push($formNodes, ...$settingsForm->nodes()); + } + + // getBooleanEnvOptions()'s optgroup carries its own `options` as a Collection (fine for + // its one existing caller, which reshapes it before use) — a Form control's options + // must be plain-array JSON-safe all the way down, so that gets flattened here rather + // than changing the shared method's return shape for every other caller. + $envOptions = array_map(fn(array $group) => [...$group, 'options' => $group['options']->all()], SelectOptions::getBooleanEnvOptions()); + + $formNodes[] = Field::make(t('Enabled for customers to select during checkout?', category: 'commerce'), Combobox::make('isFrontendEnabled')->options([ + ['label' => t('Yes'), 'value' => '1'], + ['label' => t('No'), 'value' => '0'], + ...$envOptions, + ])); + + $formNodes[] = Field::make(t('Match Order', category: 'commerce'), ConditionBuilder::make('orderCondition') + ->conditionClass(GatewayOrderCondition::class) + ->forProjectConfig() + ->value($values['orderCondition'] ?? [])) + ->instructions(t('Create rules that allow this gateway to match the order.', category: 'commerce')); + $formNodes[] = Field::make(t('Match Billing Address', category: 'commerce'), ConditionBuilder::make('billingAddressCondition') + ->conditionClass(GatewayAddressCondition::class) + ->forProjectConfig() + ->value($values['billingAddressCondition'] ?? [])) + ->instructions(t('Create rules that allow this gateway to match the billing address.', category: 'commerce')); + $formNodes[] = Field::make(t('Match Shipping Address', category: 'commerce'), ConditionBuilder::make('shippingAddressCondition') + ->conditionClass(GatewayAddressCondition::class) + ->forProjectConfig() + ->value($values['shippingAddressCondition'] ?? [])) + ->instructions(t('Create rules that allow this gateway to match the shipping address.', category: 'commerce')); + + return Form::make($formNodes); } public function save(Request $request): Response @@ -120,14 +336,26 @@ public function save(Request $request): Response abort_if($type === null, 400, 'Missing gateway type'); $gatewayId = $request->input('id'); + $bareGateway = $gatewayService->createGateway($type); + + // A Gateway type switch can leave a paymentType posted that the new type doesn't + // offer (e.g. Manual only offers "authorize") — the client corrects its display once + // the reactive round trip returns the new options, but a submit racing ahead of that + // (or bypassing it) shouldn't be able to persist a value the type never presented as + // a choice, so this falls back to that type's first option instead. + $paymentType = $request->input('paymentType'); + if (!array_key_exists($paymentType, $bareGateway->getPaymentTypeOptions())) { + $paymentType = array_key_first($bareGateway->getPaymentTypeOptions()); + } + $config = [ 'id' => $gatewayId, 'type' => $type, 'name' => $request->input('name'), 'handle' => $request->input('handle'), - 'paymentType' => $request->input('paymentTypes.' . $type . '.paymentType'), + 'paymentType' => $paymentType, 'isFrontendEnabled' => $request->input('isFrontendEnabled'), - 'settings' => $request->input('types.' . $type), + 'settings' => Arr::only($request->input(), array_keys($bareGateway->getSettings())), ]; // Handle order condition if it's in the request @@ -163,7 +391,7 @@ public function save(Request $request): Response $gateway = $gatewayService->createGateway($config); if (!$gatewayService->saveGateway($gateway)) { - return $this->asModelFailure($gateway, t('Couldn\'t save gateway.', category: 'commerce'), 'gateway'); + return $this->asModelFailure($gateway, t('Couldn’t save gateway.', category: 'commerce'), 'gateway'); } return $this->asModelSuccess($gateway, t('Gateway saved.', category: 'commerce'), 'gateway'); @@ -190,7 +418,7 @@ public function reorder(Request $request): Response $ids = json_decode($request->input('ids'), true); if (!app(Gateways::class)->reorderGateways($ids)) { - return $this->asFailure(t('Couldn\'t reorder gateways.', category: 'commerce')); + return $this->asFailure(t('Couldn’t reorder gateways.', category: 'commerce')); } return $this->asSuccess(); diff --git a/src/Http/Controllers/Settings/GeneralSettingsController.php b/src/Http/Controllers/Settings/GeneralSettingsController.php new file mode 100644 index 0000000000..a0bd5ebbd8 --- /dev/null +++ b/src/Http/Controllers/Settings/GeneralSettingsController.php @@ -0,0 +1,109 @@ + t('General Settings', category: 'commerce'), 'href' => cp_url('commerce/settings/general')]; + } + + public function edit(): CpScreenResponse + { + $settings = $this->plugin->getSettings(); + $config = Config::get('craft.commerce', null); + + $overrideWarning = function($key) use ($config) { + if ($config && isset($config[$key])) { + return t("This is being overridden by the {setting} config setting in `config/{file}.php`.", [ + 'setting' => $key, + 'file' => 'commerce', + ], category: 'commerce'); + } + + return null; + }; + + $form = Form::make([ + Heading::make('units-heading', t('Units', category: 'commerce'))->level(3), + Field::make(t('Weight Unit'), Combobox::make('weightUnits') + ->options(array_map(fn($unit, $label) => ['value' => $unit, 'label' => $label], array_keys($settings->getWeightUnitsOptions()), $settings->getWeightUnitsOptions())) + ->showAllOnEmpty()) + ->required() + ->instructions(t('The unit of measurement that should be used when specifying product weights.', category: 'commerce')), + Field::make(t('Dimension Unit'), Combobox::make('dimensionUnits') + ->options(array_map(fn($unit, $label) => ['value' => $unit, 'label' => $label], array_keys($settings->getDimensionUnits()), $settings->getDimensionUnits())) + ->showAllOnEmpty()) + ->required() + ->instructions(t('The unit of measurement that should be used when specifying product dimensions.', category: 'commerce')), + Separator::make('default-view-separator'), + Heading::make('default-view-heading', t('Control Panel Settings', category: 'commerce'))->level(3), + Field::make(t('Default View'), Combobox::make('defaultView') + ->options(array_map(fn($unit, $label) => ['value' => $unit, 'label' => $label], array_keys($settings->getDefaultViewOptions()), $settings->getDefaultViewOptions())) + ->showAllOnEmpty()) + ->required() + ->warning($overrideWarning('defaultView')) + ->instructions(t('Default Commerce control panel view. If the user does not have permission it will fall back to a location they can access.', category: 'commerce')), + ]); + + $form = $this->formResolver->resolve($form, new FormContext( + namespace: 'settings', + values: [ + 'settings' => [ + 'weightUnits' => $settings->weightUnits, + 'dimensionUnits' => $settings->dimensionUnits, + 'defaultView' => $settings->defaultView, + ], + ], + mode: $this->generalConfig->allowAdminChanges ? ControlMode::Editable : ControlMode::ReadOnly, + )); + + $title = t('General Settings', category: 'commerce'); + + return $this->cpScreenResponse() + ->title($title) + ->crumbs($this->crumbs()) + ->redirectUrl('commerce/settings/general') + ->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'saveSettings']), + ], + ]); + } + + public function saveSettings(Request $request): Response|string + { + $settings = $request->input('settings'); + $pluginSettingsSaved = Plugins::savePluginSettings($this->plugin, $settings); + + if (!$pluginSettingsSaved) { + return $this->asFailure(t('Couldn’t save settings.', category: 'commerce')); + } + + return $this->asSuccess(t('Settings saved.', category: 'commerce')); + } +} diff --git a/src/Http/Controllers/Settings/LineItemStatusesController.php b/src/Http/Controllers/Settings/LineItemStatusesController.php index 6e44533790..6b100755c9 100644 --- a/src/Http/Controllers/Settings/LineItemStatusesController.php +++ b/src/Http/Controllers/Settings/LineItemStatusesController.php @@ -4,12 +4,23 @@ namespace CraftCms\Commerce\Http\Controllers\Settings; -use CraftCms\Cms\Config\GeneralConfig; -use CraftCms\Cms\Http\RespondsWithFlash; +use CraftCms\Cms\Cp\FormFields; +use CraftCms\Cms\Form\Controls\Choice; +use CraftCms\Cms\Form\Controls\Handle; +use CraftCms\Cms\Form\Controls\Lightswitch; +use CraftCms\Cms\Form\Controls\Text; +use CraftCms\Cms\Form\Enums\ChoicePresentation; +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\Table; use CraftCms\Cms\Http\Responses\CpScreenResponse; +use CraftCms\Cms\Support\Html; use CraftCms\Cms\Support\Json; -use CraftCms\Cms\View\TemplateMode; -use CraftCms\Commerce\Database\Table; +use CraftCms\Commerce\Database\Table as DbTable; use CraftCms\Commerce\Order\Data\LineItemStatus; use CraftCms\Commerce\Order\LineItemStatuses; use CraftCms\Commerce\Store\Data\Store; @@ -18,34 +29,76 @@ use Illuminate\Support\Facades\DB; use Symfony\Component\HttpFoundation\Response; -use function CraftCms\Cms\pageTemplate; +use function CraftCms\Cms\cp_url; use function CraftCms\Cms\t; -readonly class LineItemStatusesController +class LineItemStatusesController extends BaseSettingsController { - use RespondsWithFlash; + private const array STATUS_COLORS = ['green', 'orange', 'red', 'blue', 'yellow', 'pink', 'purple', 'turquoise', 'light', 'grey', 'black']; - private bool $readOnly; - - public function __construct(GeneralConfig $generalConfig) + protected function getSectionCrumb(): array { - $this->readOnly = !$generalConfig->allowAdminChanges; + return ['label' => t('Line Item Statuses', category: 'commerce'), 'href' => cp_url('commerce/settings/lineitemstatuses')]; } - public function index(): string + public function index(): CpScreenResponse { - $lineItemStatuses = []; $stores = app(Stores::class)->getAllStores(); - - $stores->each(function(Store $store) use (&$lineItemStatuses) { - $lineItemStatuses[$store->handle] = app(LineItemStatuses::class)->getAllLineItemStatuses($store->id); + $isMultiStore = $stores->count() > 1; + + // Every store's table can plant its own create action in the page's shared actions + // slot, so with more than one store, only the first store's table gets one — a single + // combined "New line item status" menu covering every store, rather than one button apiece. + $createMenuItems = $this->readOnly ? [] : $stores->map(fn(Store $store) => [ + 'label' => $store->name, + 'url' => cp_url("commerce/settings/lineitemstatuses/{$store->handle}/new"), + ])->all(); + $createMenuAssigned = false; + + $nodes = []; + $stores->each(function(Store $store) use (&$nodes, $isMultiStore, $createMenuItems, &$createMenuAssigned) { + if ($isMultiStore) { + $nodes[] = Heading::make("{$store->handle}-heading", $store->name); + } + + $rows = app(LineItemStatuses::class)->getAllLineItemStatuses($store->id) + ->map(fn(LineItemStatus $lineItemStatus) => [ + 'id' => $lineItemStatus->id, + // getLabelHtml() already encodes the name it interpolates — safe to wrap directly. + 'name' => ['html' => Html::a($lineItemStatus->getLabelHtml(), $lineItemStatus->getCpEditUrl(), ['class' => 'cell-bold'])], + 'handle' => ['html' => FormFields::copytextHtml(['value' => $lineItemStatus->handle, 'monospace' => true])], + 'default' => $lineItemStatus->default ? ['icon' => 'check', 'label' => t('Yes')] : '', + ]) + ->all(); + + $nodes[] = Table::make("{$store->handle}-line-item-statuses") + ->columns([ + ['key' => 'name', 'label' => t('Name')], + ['key' => 'handle', 'label' => t('Handle')], + ['key' => 'default', 'label' => t('Default Status?', category: 'commerce')], + ]) + ->rows($rows) + ->emptyMessage(t('No line item statuses exist yet.', category: 'commerce')) + ->when(!$createMenuAssigned && $createMenuItems, function(Table $table) use ($createMenuItems, &$createMenuAssigned) { + $table->createActionMenu(t('New line item status', category: 'commerce'), $createMenuItems); + $createMenuAssigned = true; + }) + ->when(!$this->readOnly, fn(Table $table) => $table + ->reorderable(action([self::class, 'reorder'])) + ->deletable( + action([self::class, 'archive']), + t('Are you sure you want to delete this line item status? This will set all line items with this status to no status.', category: 'commerce'), + )); }); - return pageTemplate('commerce/settings/lineitemstatuses/index', [ - 'lineItemStatuses' => $lineItemStatuses, - 'stores' => $stores->all(), - 'readOnly' => $this->readOnly, - ], TemplateMode::Cp); + $title = t('Line Item Statuses', category: 'commerce'); + + return $this->cpScreenResponse() + ->title($title) + ->crumbs($this->crumbs()) + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve(Form::make($nodes), new FormContext()), + ]); } public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse @@ -61,15 +114,13 @@ public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResp $lineItemStatus = new LineItemStatus(['storeId' => $store->id]); } - $statusColors = ['green', 'orange', 'red', 'blue', 'yellow', 'pink', 'purple', 'turquoise', 'light', 'grey', 'black']; - $nextAvailableColor = null; - if ($lineItemStatus->id) { $title = $lineItemStatus->name; + $statusColor = $lineItemStatus->color; } else { $title = t('Create a new line item status', category: 'commerce'); - $availableColors = $statusColors; + $availableColors = self::STATUS_COLORS; app(LineItemStatuses::class)->getAllLineItemStatuses($store->id)->each(function(LineItemStatus $status) use (&$availableColors) { $key = array_search($status->color, $availableColors, true); if ($key !== false) { @@ -77,24 +128,63 @@ public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResp } }); - $nextAvailableColor = !empty($availableColors) ? array_shift($availableColors) : 'green'; + $statusColor = !empty($availableColors) ? array_shift($availableColors) : 'green'; + } + + $colorOptions = array_map(fn(string $color) => [ + 'label' => ucfirst(t($color, category: 'commerce')), + 'labelHtml' => Html::tag('span', '', ['class' => "status $color"]) . Html::encode(ucfirst(t($color, category: 'commerce'))), + 'value' => $color, + ], self::STATUS_COLORS); + + $handle = Handle::make('handle'); + if (!$lineItemStatus->id) { + $handle->source('name'); + } + + $formNodes = [ + HiddenField::make('storeId'), + ]; + + if ($lineItemStatus->id) { + $formNodes[] = HiddenField::make('sortOrder'); + $formNodes[] = HiddenField::make('id'); } - return new CpScreenResponse() + $formNodes[] = Field::make(t('Name', category: 'commerce'), Text::make('name')->autofocus()) + ->instructions(t('What this status will be called in the control panel.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Handle', category: 'commerce'), $handle) + ->instructions(t('How you’ll refer to this status in the templates.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Color', category: 'commerce'), Choice::make('color')->presentation(ChoicePresentation::Radios)->options($colorOptions)) + ->instructions(t('Choose a color to represent the order’s status', category: 'commerce')); + $formNodes[] = Field::make(t('New line items get this status by default when the order is completed', category: 'commerce'), Lightswitch::make('default')); + + $form = $this->formResolver->resolve(Form::make($formNodes), new FormContext( + values: [ + 'storeId' => $store->id, + 'sortOrder' => $lineItemStatus->sortOrder, + 'id' => $lineItemStatus->id, + 'name' => $lineItemStatus->name, + 'handle' => $lineItemStatus->handle, + 'color' => $statusColor, + 'default' => $lineItemStatus->default, + ], + 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('Line Item Statuses', category: 'commerce'), 'url' => 'commerce/settings/lineitemstatuses'], - ]) - ->selectedSubnavItem('settings') + ->crumbs($lineItemStatus->id ? $this->crumbs(['label' => $title]) : $this->crumbs()) ->action('commerce/line-item-statuses/save') ->redirectUrl('commerce/settings/lineitemstatuses') - ->contentTemplate('commerce/settings/lineitemstatuses/_edit', [ - 'lineItemStatus' => $lineItemStatus, - 'statusColors' => $statusColors, - 'nextAvailableColor' => $nextAvailableColor, - 'readOnly' => $this->readOnly, + ->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'save']), + ], ]); } @@ -112,10 +202,10 @@ public function save(Request $request): Response $lineItemStatus->default = (bool)$request->input('default'); if (!app(LineItemStatuses::class)->saveLineItemStatus($lineItemStatus)) { - return $this->asModelFailure($lineItemStatus, t('Couldn\'t save line item status.', category: 'commerce'), 'lineItemStatus'); + return $this->asModelFailure($lineItemStatus, t('Couldn’t save line item status.', category: 'commerce'), 'lineItemStatus'); } - return $this->asModelSuccess($lineItemStatus, t('Order status saved.', category: 'commerce'), 'lineItemStatus'); + return $this->asModelSuccess($lineItemStatus, t('Line item status saved.', category: 'commerce'), 'lineItemStatus'); } public function reorder(Request $request): Response @@ -126,7 +216,10 @@ public function reorder(Request $request): Response $ids = Json::decode($request->input('ids')); if (!app(LineItemStatuses::class)->reorderLineItemStatuses($ids)) { - return $this->asFailure(t('Couldn\'t reorder Line Item Statuses.', category: 'commerce')); + // The double space before "Line" matches lang/en/commerce.php's existing key + // exactly (a pre-existing typo carried over from the legacy Yii2 controller) — + // don't "fix" the spacing here without also updating the lang file. + return $this->asFailure(t('Couldn’t reorder Line Item Statuses.', category: 'commerce')); } return $this->asSuccess(); @@ -139,10 +232,10 @@ public function archive(Request $request): Response $lineItemStatusId = $request->input('id'); abort_if(!$lineItemStatusId, 400, 'Missing line item status id'); - $storeId = DB::table(Table::LINEITEMSTATUSES)->where('id', $lineItemStatusId)->value('storeId'); + $storeId = DB::table(DbTable::LINEITEMSTATUSES)->where('id', $lineItemStatusId)->value('storeId'); if (!$storeId || !app(LineItemStatuses::class)->archiveLineItemStatusById((int)$lineItemStatusId, $storeId)) { - return $this->asFailure(t('Couldn\'t archive Line Item Status.', category: 'commerce')); + return $this->asFailure(t('Couldn’t archive Line Item Status.', category: 'commerce')); } return $this->asSuccess(); diff --git a/src/Http/Controllers/Settings/OrderSettingsController.php b/src/Http/Controllers/Settings/OrderSettingsController.php index 910214cfad..5a5663ae3b 100644 --- a/src/Http/Controllers/Settings/OrderSettingsController.php +++ b/src/Http/Controllers/Settings/OrderSettingsController.php @@ -4,39 +4,60 @@ namespace CraftCms\Commerce\Http\Controllers\Settings; -use CraftCms\Cms\Config\GeneralConfig; -use CraftCms\Cms\Http\RespondsWithFlash; +use CraftCms\Cms\Form\Controls\FieldLayoutDesigner; +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\Http\Responses\CpScreenResponse; use CraftCms\Cms\Support\Facades\Fields; use CraftCms\Cms\Support\Facades\ProjectConfig; use CraftCms\Cms\Support\Str; -use CraftCms\Cms\View\TemplateMode; use CraftCms\Commerce\Order\Elements\Order; use CraftCms\Commerce\Order\Orders; use Symfony\Component\HttpFoundation\Response; -use function CraftCms\Cms\pageTemplate; +use function CraftCms\Cms\cp_url; use function CraftCms\Cms\t; -readonly class OrderSettingsController +class OrderSettingsController extends BaseSettingsController { - use RespondsWithFlash; - - private bool $readOnly; - - public function __construct(GeneralConfig $generalConfig) + protected function getSectionCrumb(): array { - $this->readOnly = !$generalConfig->allowAdminChanges; + return ['label' => t('Order Fields', category: 'commerce'), 'href' => cp_url('commerce/settings/ordersettings')]; } - public function edit(): string + public function edit(): CpScreenResponse { $fieldLayout = Fields::getLayoutByType(Order::class); + $title = t('Order Settings', category: 'commerce'); + + $form = Form::make([ + Field::make(null, FieldLayoutDesigner::make('fieldLayout') + ->elementType(Order::class) + ->withCardViewDesigner()), + ]); - return pageTemplate('commerce/settings/ordersettings/_edit', [ - 'fieldLayout' => $fieldLayout, - 'title' => t('Order Settings', category: 'commerce'), - 'readOnly' => $this->readOnly, - ], TemplateMode::Cp); + return $this->cpScreenResponse() + ->title($title) + ->crumbs($this->crumbs()) + ->redirectUrl('commerce/settings/ordersettings') + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve($form, new FormContext( + values: [ + 'fieldLayout' => [ + 'id' => $fieldLayout->id, + 'uid' => $fieldLayout->uid, + ...($fieldLayout->getConfig() ?? []), + ], + ], + mode: $this->readOnly ? ControlMode::ReadOnly : ControlMode::Editable, + )), + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'save']), + ], + ]); } public function save(): Response @@ -56,7 +77,7 @@ public function save(): Response ]; if (!$fieldLayout->validate()) { - return $this->asFailure(t('Couldn\'t save order fields.', category: 'commerce')); + return $this->asFailure(t('Couldn’t save order fields.', category: 'commerce')); } if ($currentOrderFieldLayout = ProjectConfig::get(Orders::CONFIG_FIELDLAYOUT_KEY)) { diff --git a/src/Http/Controllers/Settings/OrderStatusesController.php b/src/Http/Controllers/Settings/OrderStatusesController.php index eecbe1c667..89103ad29f 100644 --- a/src/Http/Controllers/Settings/OrderStatusesController.php +++ b/src/Http/Controllers/Settings/OrderStatusesController.php @@ -5,12 +5,23 @@ namespace CraftCms\Commerce\Http\Controllers\Settings; use craft\db\Query; -use CraftCms\Cms\Config\GeneralConfig; -use CraftCms\Cms\Http\RespondsWithFlash; +use CraftCms\Cms\Cp\FormFields; +use CraftCms\Cms\Form\Controls\Choice; +use CraftCms\Cms\Form\Controls\Handle; +use CraftCms\Cms\Form\Controls\Lightswitch; +use CraftCms\Cms\Form\Controls\Text; +use CraftCms\Cms\Form\Enums\ChoicePresentation; +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\Table; use CraftCms\Cms\Http\Responses\CpScreenResponse; +use CraftCms\Cms\Support\Html; use CraftCms\Cms\Support\Json; -use CraftCms\Cms\View\TemplateMode; -use CraftCms\Commerce\Database\Table; +use CraftCms\Commerce\Database\Table as DbTable; use CraftCms\Commerce\Email\Data\Email; use CraftCms\Commerce\Email\Emails; use CraftCms\Commerce\Order\Data\OrderStatus; @@ -22,35 +33,86 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; 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 OrderStatusesController +class OrderStatusesController extends BaseSettingsController { - use RespondsWithFlash; + private const array STATUS_COLORS = ['green', 'orange', 'red', 'blue', 'yellow', 'pink', 'purple', 'turquoise', 'light', 'grey', 'black']; - private bool $readOnly; - - public function __construct(GeneralConfig $generalConfig) + protected function getSectionCrumb(): array { - $this->readOnly = !$generalConfig->allowAdminChanges; + return ['label' => t('Order Statuses', category: 'commerce'), 'href' => cp_url('commerce/settings/orderstatuses')]; } - public function index(): string + public function index(): CpScreenResponse { - $orderStatuses = []; $stores = app(Stores::class)->getAllStores(); - - $stores->each(function(Store $store) use (&$orderStatuses) { - $orderStatuses[$store->handle] = app(OrderStatuses::class)->getAllOrderStatuses($store->id); + $isMultiStore = $stores->count() > 1; + + // Every store's table can plant its own create action in the page's shared actions + // slot, so with more than one store, only the first store's table gets one — a single + // combined "New order status" menu covering every store, rather than one button apiece. + $createMenuItems = $this->readOnly ? [] : $stores->map(fn(Store $store) => [ + 'label' => $store->name, + 'url' => cp_url("commerce/settings/orderstatuses/{$store->handle}/new"), + ])->all(); + $createMenuAssigned = false; + + $nodes = []; + $stores->each(function(Store $store) use (&$nodes, $isMultiStore, $createMenuItems, &$createMenuAssigned) { + if ($isMultiStore) { + $nodes[] = Heading::make("{$store->handle}-heading", $store->name); + } + + $rows = app(OrderStatuses::class)->getAllOrderStatuses($store->id) + ->map(function(OrderStatus $orderStatus) { + $emailCount = count($orderStatus->getEmailIds()); + + return [ + 'id' => $orderStatus->id, + // getLabelHtml() already Html::encode()s the name it interpolates — safe to + // wrap directly, same as the pre-conversion Twig index did. + 'name' => ['html' => Html::a($orderStatus->getLabelHtml(), $orderStatus->getCpEditUrl(), ['class' => 'cell-bold'])], + 'handle' => ['html' => FormFields::copytextHtml(['value' => $orderStatus->handle, 'monospace' => true])], + 'hasEmails' => $emailCount > 0 ? $emailCount : '', + 'default' => $orderStatus->default ? ['icon' => 'check', 'label' => t('Yes')] : '', + ]; + }) + // getAllOrderStatuses() ends in a ->filter(), which (like the rest of Illuminate's + // Collection) preserves original keys rather than reindexing — so with an item + // filtered out upstream, ->all() can hand back a non-sequential array that + // json_encodes as a JS object instead of an array. ->values() guarantees a plain list. + ->values() + ->all(); + + $nodes[] = Table::make("{$store->handle}-order-statuses") + ->columns([ + ['key' => 'name', 'label' => t('Name')], + ['key' => 'handle', 'label' => t('Handle')], + ['key' => 'hasEmails', 'label' => t('Has Emails?', category: 'commerce')], + ['key' => 'default', 'label' => t('Default Status?', category: 'commerce')], + ]) + ->rows($rows) + ->emptyMessage(t('No order statuses exist yet.', category: 'commerce')) + ->when(!$createMenuAssigned && $createMenuItems, function(Table $table) use ($createMenuItems, &$createMenuAssigned) { + $table->createActionMenu(t('New order status', category: 'commerce'), $createMenuItems); + $createMenuAssigned = true; + }) + ->when(!$this->readOnly, fn(Table $table) => $table + ->reorderable(action([self::class, 'reorder'])) + ->deletable(action([self::class, 'delete']))); }); - return pageTemplate('commerce/settings/orderstatuses/index', [ - 'orderStatuses' => $orderStatuses, - 'stores' => $stores->all(), - 'readOnly' => $this->readOnly, - ], TemplateMode::Cp); + $title = t('Order Statuses', category: 'commerce'); + + return $this->cpScreenResponse() + ->title($title) + ->crumbs($this->crumbs()) + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve(Form::make($nodes), new FormContext()), + ]); } public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse @@ -66,15 +128,13 @@ public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResp $orderStatus = new OrderStatus(['storeId' => $store->id]); } - $statusColors = ['green', 'orange', 'red', 'blue', 'yellow', 'pink', 'purple', 'turquoise', 'light', 'grey', 'black']; - $nextAvailableColor = null; - if ($orderStatus->id) { $title = $orderStatus->name; + $statusColor = $orderStatus->color; } else { $title = t('Create a new order status', category: 'commerce'); - $availableColors = $statusColors; + $availableColors = self::STATUS_COLORS; app(OrderStatuses::class)->getAllOrderStatuses($store->id)->each(function(OrderStatus $status) use (&$availableColors) { $key = array_search($status->color, $availableColors, true); if ($key !== false) { @@ -82,27 +142,86 @@ public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResp } }); - $nextAvailableColor = !empty($availableColors) ? array_shift($availableColors) : 'green'; + $statusColor = !empty($availableColors) ? array_shift($availableColors) : 'green'; } - $emails = app(Emails::class)->getAllEmails($store->id)->mapWithKeys(fn(Email $email) => [$email->id => $email->name])->all(); + $colorOptions = array_map(fn(string $color) => [ + 'label' => ucfirst(t($color, category: 'commerce')), + 'labelHtml' => Html::tag('span', '', ['class' => "status $color"]) . Html::encode(ucfirst(t($color, category: 'commerce'))), + 'value' => $color, + ], self::STATUS_COLORS); + + $emailOptions = app(Emails::class)->getAllEmails($store->id) + ->map(fn(Email $email) => ['label' => $email->name, 'value' => $email->id]) + ->all(); + + $emailsNode = Field::make(t('Status Emails', category: 'commerce'), Choice::make('emails')->multiple()->options($emailOptions)) + ->instructions(t('Select the emails that will be sent when transitioning to this status.', category: 'commerce')) + ->warning($emailOptions === [] ? t('You currently have no emails configured to select for this status.', category: 'commerce') : null); + + // An existing default status can't be un-defaulted from its own screen — promote a + // different status to default instead. A brand new status defaults to on when it'll be + // the store's first (nothing else to be the default), same as the legacy behavior. + $isDefault = $orderStatus->default + || app(OrderStatuses::class)->getAllOrderStatuses($store->id)->count() === 0; + + $defaultNode = $orderStatus->default + ? HiddenField::make('default') + : Field::make(t('New orders get this status by default', category: 'commerce'), Lightswitch::make('default')); + + $handle = Handle::make('handle'); + if (!$orderStatus->id) { + $handle->source('name'); + } + + $formNodes = [ + HiddenField::make('storeId'), + ]; + + if ($orderStatus->id) { + $formNodes[] = HiddenField::make('sortOrder'); + $formNodes[] = HiddenField::make('id'); + } - return new CpScreenResponse() + $formNodes[] = Field::make(t('Name', category: 'commerce'), Text::make('name')->autofocus()) + ->instructions(t('What this status will be called in the control panel.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Handle', category: 'commerce'), $handle) + ->instructions(t('How you’ll refer to this status in the templates.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Description', category: 'commerce'), Text::make('description')) + ->instructions(t('Order Status description.', category: 'commerce')); + $formNodes[] = Field::make(t('Color', category: 'commerce'), Choice::make('color')->presentation(ChoicePresentation::Radios)->options($colorOptions)) + ->instructions(t('Choose a color to represent the order’s status', category: 'commerce')); + $formNodes[] = $emailsNode; + $formNodes[] = $defaultNode; + + $form = $this->formResolver->resolve(Form::make($formNodes), new FormContext( + values: [ + 'storeId' => $store->id, + 'sortOrder' => $orderStatus->sortOrder, + 'id' => $orderStatus->id, + 'name' => $orderStatus->name, + 'handle' => $orderStatus->handle, + 'description' => $orderStatus->description, + 'color' => $statusColor, + 'emails' => $orderStatus->getEmailIds(), + 'default' => $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('Order Statuses', category: 'commerce'), 'url' => 'commerce/settings/orderstatuses'], - ]) - ->selectedSubnavItem('settings') + ->crumbs($orderStatus->id ? $this->crumbs(['label' => $title]) : $this->crumbs()) ->action('commerce/order-statuses/save') ->redirectUrl('commerce/settings/orderstatuses') - ->contentTemplate('commerce/settings/orderstatuses/_edit', [ - 'orderStatus' => $orderStatus, - 'statusColors' => $statusColors, - 'nextAvailableColor' => $nextAvailableColor, - 'emails' => $emails, - 'readOnly' => $this->readOnly, + ->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'save']), + ], ]); } @@ -124,13 +243,13 @@ public function save(Request $request): Response if (!$id) { $orderStatus->sortOrder = new Query() - ->from(Table::ORDERSTATUSES) + ->from(DbTable::ORDERSTATUSES) ->where(['storeId' => $storeId]) ->max('[[sortOrder]]') + 1; } if (!app(OrderStatuses::class)->saveOrderStatus($orderStatus, $emailIds)) { - return $this->asModelFailure($orderStatus, t('Couldn\'t save order status.', category: 'commerce'), 'orderStatus'); + return $this->asModelFailure($orderStatus, t('Couldn’t save order status.', category: 'commerce'), 'orderStatus'); } return $this->asModelSuccess($orderStatus, t('Order status saved.', category: 'commerce'), 'orderStatus'); @@ -164,7 +283,7 @@ public function reorder(Request $request): Response $ids = Json::decode($request->input('ids')); if (!app(OrderStatuses::class)->reorderOrderStatuses($ids)) { - return $this->asFailure(t('Couldn\'t reorder Order Statuses.', category: 'commerce')); + return $this->asFailure(t('Couldn’t reorder Order Statuses.', category: 'commerce')); } return $this->asSuccess(); @@ -177,14 +296,14 @@ public function delete(Request $request): Response $orderStatusId = $request->input('id'); abort_if(!$orderStatusId, 400, 'Missing order status id'); - $storeId = DB::table(Table::ORDERSTATUSES)->where('id', $orderStatusId)->value('storeId'); + $storeId = DB::table(DbTable::ORDERSTATUSES)->where('id', $orderStatusId)->value('storeId'); if ($storeId) { $this->requireStoreAccess((int)$storeId); } if (!$storeId || !app(OrderStatuses::class)->deleteOrderStatusById((int)$orderStatusId, $storeId)) { - return $this->asFailure(t('Couldn\'t archive Order Status.', category: 'commerce')); + return $this->asFailure(t('Couldn’t archive Order Status.', category: 'commerce')); } return $this->asSuccess(); diff --git a/src/Http/Controllers/Settings/PaymentCurrenciesController.php b/src/Http/Controllers/Settings/PaymentCurrenciesController.php deleted file mode 100644 index fc7e6adcf8..0000000000 --- a/src/Http/Controllers/Settings/PaymentCurrenciesController.php +++ /dev/null @@ -1,121 +0,0 @@ -resolveStore($storeHandle); - $storeHandle = $store->handle; - - $currencies = app(PaymentCurrencies::class)->getAllPaymentCurrencies($store->id); - - return $this->storeManagementCpScreen($storeHandle) - ->additionalButtonsHtml(Html::a( - t('New currency', category: 'commerce'), "commerce/store-management/$storeHandle/payment-currencies/new", - ['class' => 'btn submit add icon'] - )) - ->contentTemplate('commerce/store-management/paymentcurrencies/index', ['currencies' => $currencies, 'store' => $store]); - } - - public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse - { - $store = $this->resolveStore($storeHandle); - $storeHandle = $store->handle; - - if ($id) { - $currency = app(PaymentCurrencies::class)->getPaymentCurrencyById($id, $store->id); - abort_if($currency === null || $currency->storeId !== $store->id, 404); - } else { - $currency = new PaymentCurrency(['storeId' => $store->id]); - } - - // @TODO Use the full currency name instead of the ISO code for the page title - $title = $currency->id ? $currency->iso : t('Create a new currency', category: 'commerce'); - - $storeCurrency = app(PaymentCurrencies::class)->getPrimaryPaymentCurrencyIso(); - $currencyOptions = app(Currencies::class)->getAllCurrenciesList(); - $hasCompletedOrders = Order::find()->isCompleted(true)->exists(); - - $formatter = I18N::getFormatter(); - - $metaSidebarHtml = $currency->id ? Cp::metadataHtml([ - t('Created at') => $formatter->asDateTime($currency->dateCreated, Formatter::FORMAT_WIDTH_SHORT), - t('Updated at') => $formatter->asDateTime($currency->dateUpdated, Formatter::FORMAT_WIDTH_SHORT), - ]) : ''; - - return $this->storeManagementCpScreen($storeHandle, false) - ->addCrumb(t('Payment Currencies', category: 'commerce'), "commerce/store-management/$storeHandle/payment-currencies") - ->metaSidebarHtml($metaSidebarHtml) - ->action('commerce/payment-currencies/save') - ->redirectUrl("commerce/store-management/$storeHandle/payment-currencies") - ->submitButtonLabel(t('Save')) - ->contentTemplate('commerce/store-management/paymentcurrencies/_edit', [ - 'id' => $id, - 'currency' => $currency, - 'title' => $title, - 'storeCurrency' => $storeCurrency, - 'currencyOptions' => $currencyOptions, - 'store' => $store, - 'hasCompletedOrders' => $hasCompletedOrders, - ]); - } - - public function save(Request $request): Response - { - $currency = new PaymentCurrency(); - - $currency->id = $request->input('currencyId') ? (int)$request->input('currencyId') : null; - $currency->storeId = (int)$request->input('storeId'); - $this->requireStoreAccess($currency->storeId); - $currency->iso = $request->input('iso'); - $currency->rate = (float)$request->input('rate', 1); - - if (app(PaymentCurrencies::class)->savePaymentCurrency($currency)) { - return $this->asModelSuccess($currency, t('Currency saved.', category: 'commerce'), 'currency'); - } - - return $this->asModelFailure($currency, t('Couldn\'t save currency.', category: 'commerce'), 'currency'); - } - - public function delete(Request $request): Response - { - abort_unless($request->expectsJson(), 400); - - $id = $request->input('id'); - abort_if(!$id, 400, 'Missing currency id'); - - $currency = app(PaymentCurrencies::class)->getPaymentCurrencyById((int)$id); - if ($currency) { - $this->requireStoreAccess($currency->storeId); - } - - if (!app(PaymentCurrencies::class)->deletePaymentCurrencyById((int)$id)) { - return $this->asFailure(); - } - - return $this->asSuccess(); - } -} diff --git a/src/Http/Controllers/Settings/PdfsController.php b/src/Http/Controllers/Settings/PdfsController.php index 704e590ecc..5802eb2017 100644 --- a/src/Http/Controllers/Settings/PdfsController.php +++ b/src/Http/Controllers/Settings/PdfsController.php @@ -4,11 +4,23 @@ namespace CraftCms\Commerce\Http\Controllers\Settings; -use CraftCms\Cms\Config\GeneralConfig; -use CraftCms\Cms\Http\RespondsWithFlash; +use CraftCms\Cms\Cp\FormFields; +use CraftCms\Cms\Cp\SelectOptions; +use CraftCms\Cms\Form\Controls\Choice; +use CraftCms\Cms\Form\Controls\Combobox; +use CraftCms\Cms\Form\Controls\Handle; +use CraftCms\Cms\Form\Controls\Lightswitch; +use CraftCms\Cms\Form\Controls\Number; +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\Table; use CraftCms\Cms\Http\Responses\CpScreenResponse; use CraftCms\Cms\Support\Json; -use CraftCms\Cms\View\TemplateMode; use CraftCms\Commerce\Helpers\Locale as LocaleHelper; use CraftCms\Commerce\Pdf\Data\Pdf; use CraftCms\Commerce\Pdf\Models\Pdf as PdfRecord; @@ -18,34 +30,72 @@ 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 PdfsController +class PdfsController extends BaseSettingsController { - use RespondsWithFlash; - - private bool $readOnly; - - public function __construct(GeneralConfig $generalConfig) + protected function getSectionCrumb(): array { - $this->readOnly = !$generalConfig->allowAdminChanges; + return ['label' => t('PDFs', category: 'commerce'), 'href' => cp_url('commerce/settings/pdfs')]; } - public function index(): string + public function index(): CpScreenResponse { - $pdfs = []; $stores = app(Stores::class)->getAllStores(); + $isMultiStore = $stores->count() > 1; + + // Every store's table can plant its own create action in the page's shared actions + // slot, so with more than one store, only the first store's table gets one — a single + // combined "New PDF" menu covering every store, rather than one button apiece. + $createMenuItems = $this->readOnly ? [] : $stores->map(fn(Store $store) => [ + 'label' => $store->name, + 'url' => cp_url("commerce/settings/pdfs/{$store->handle}/new"), + ])->all(); + $createMenuAssigned = false; - $stores->each(function(Store $store) use (&$pdfs) { - $pdfs[$store->handle] = app(Pdfs::class)->getAllPdfs($store->id); + $nodes = []; + $stores->each(function(Store $store) use (&$nodes, $isMultiStore, $createMenuItems, &$createMenuAssigned) { + if ($isMultiStore) { + $nodes[] = Heading::make("{$store->handle}-heading", $store->name); + } + + $rows = app(Pdfs::class)->getAllPdfs($store->id) + ->map(fn(Pdf $pdf) => [ + 'id' => $pdf->id, + 'name' => ['label' => t($pdf->name, category: 'site'), 'url' => $pdf->getCpEditUrl()], + 'handle' => ['html' => FormFields::copytextHtml(['value' => $pdf->handle, 'monospace' => true])], + 'enabled' => $pdf->enabled ? ['icon' => 'check', 'label' => t('Yes')] : '', + 'default' => $pdf->isDefault ? ['icon' => 'check', 'label' => t('Yes')] : '', + ]) + ->all(); + + $nodes[] = Table::make("{$store->handle}-pdfs") + ->columns([ + ['key' => 'name', 'label' => t('Name')], + ['key' => 'handle', 'label' => t('Handle')], + ['key' => 'enabled', 'label' => t('Enabled?', category: 'commerce')], + ['key' => 'default', 'label' => t('Default?', category: 'commerce')], + ]) + ->rows($rows) + ->emptyMessage(t('No PDFs exist yet.', category: 'commerce')) + ->when(!$createMenuAssigned && $createMenuItems, function(Table $table) use ($createMenuItems, &$createMenuAssigned) { + $table->createActionMenu(t('New PDF', category: 'commerce'), $createMenuItems); + $createMenuAssigned = true; + }) + ->when(!$this->readOnly, fn(Table $table) => $table + ->reorderable(action([self::class, 'reorder'])) + ->deletable(action([self::class, 'delete']))); }); - return pageTemplate('commerce/settings/pdfs/index', [ - 'pdfs' => $pdfs, - 'stores' => $stores->all(), - 'readOnly' => $this->readOnly, - ], TemplateMode::Cp); + $title = t('PDFs', category: 'commerce'); + + return $this->cpScreenResponse() + ->title($title) + ->crumbs($this->crumbs()) + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve(Form::make($nodes), new FormContext()), + ]); } public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse @@ -54,12 +104,6 @@ public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResp $store = app(Stores::class)->getPrimaryStore(); } - $pdfLanguageOptions = [ - PdfRecord::LOCALE_ORDER_LANGUAGE => t('The language the order was made in.', category: 'commerce'), - ]; - - $pdfLanguageOptions = array_merge($pdfLanguageOptions, LocaleHelper::getSiteAndOtherLanguages()); - if ($id) { $pdf = app(Pdfs::class)->getPdfById($id, $store->id); abort_if($pdf === null, 404); @@ -68,28 +112,106 @@ public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResp } $title = $pdf->id ? $pdf->name : t('Create a new PDF', category: 'commerce'); + $isDefault = $pdf->isDefault || app(Pdfs::class)->getAllPdfs($pdf->storeId)->count() === 0; + + // The old select field grouped these under "Site Languages"/"Other Languages" optgroup + // headers; Choice has no optgroup concept, so this flattens them into one list — every + // option still selectable, just without the section headers. + $languageOptions = collect([PdfRecord::LOCALE_ORDER_LANGUAGE => t('The language the order was made in.', category: 'commerce')]) + ->merge(LocaleHelper::getSiteAndOtherLanguages()) + ->reject(fn($label) => is_array($label)) + ->map(fn($label, $value) => ['label' => $label, 'value' => $value]) + ->values() + ->all(); - $isDefault = app(Pdfs::class)->getAllPdfs($pdf->storeId)->count() === 0 || $pdf->isDefault; - $paperOrientationOptions = Pdf::getPaperOrientationOptions(); - $paperSizeOptions = Pdf::getPaperSizeOptions(); + $paperOrientationOptions = collect(Pdf::getPaperOrientationOptions()) + ->map(fn($label, $value) => ['label' => $label, 'value' => $value]) + ->values() + ->all(); - return new CpScreenResponse() + $paperSizeOptions = collect(Pdf::getPaperSizeOptions()) + ->map(fn($label, $value) => ['label' => $label, 'value' => $value]) + ->values() + ->all(); + + $handle = Handle::make('handle'); + if (!$pdf->id) { + $handle->source('name'); + } + + $defaultNode = $pdf->isDefault + ? HiddenField::make('isDefault') + : Field::make(t('Default Order PDF', category: 'commerce'), Lightswitch::make('isDefault')) + ->instructions(t('This is the default PDF that will be rendered when requesting the order PDF.', category: 'commerce')); + + $formNodes = [ + HiddenField::make('storeId'), + ]; + + if ($pdf->id) { + $formNodes[] = HiddenField::make('id'); + } + + $formNodes[] = Field::make(t('Name', category: 'commerce'), Text::make('name')->autofocus()) + ->instructions(t('What this PDF will be called in the control panel.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Handle', category: 'commerce'), $handle) + ->instructions(t('How you’ll refer to this PDF in the templates.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Description', category: 'commerce'), Text::make('description')); + // Same template-path autosuggest as the old craft.cp.getTemplateSuggestions() JS call — + // Combobox is a free-text input with suggestions, not a closed choice. + $formNodes[] = Field::make(t('PDF Template Path', category: 'commerce'), Combobox::make('templatePath')->options(SelectOptions::getTemplateSuggestions())) + ->instructions(t('The template that the PDF should be generated from.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Order PDF Filename Format', category: 'commerce'), Text::make('fileNameFormat')->monospace()) + ->instructions(t('What the order PDF filename should look like (sans extension). You can include tags that output order properties, such as {ex1} or {ex2}.', [ + // Field::instructions() runs through the same markdown as the Twig field macro, but + // (unlike the old macro) doesn't pass raw inline HTML through — backticks instead of + // literal 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']), + ], ]); } @@ -120,7 +242,7 @@ public function save(Request $request): Response $pdf->paperOrientation = $request->input('paperOrientation'); if (!$pdfsService->savePdf($pdf)) { - return $this->asModelFailure($pdf, t('Couldn\'t save PDF.', category: 'commerce'), 'pdf'); + return $this->asModelFailure($pdf, t('Couldn’t save PDF.', category: 'commerce'), 'pdf'); } return $this->asModelSuccess($pdf, t('PDF saved.', category: 'commerce'), 'pdf'); @@ -146,7 +268,7 @@ public function reorder(Request $request): Response $ids = Json::decode($request->input('ids')); if (!app(Pdfs::class)->reorderPdfs($ids)) { - return $this->asFailure(t('Couldn\'t reorder PDFs.', category: 'commerce')); + return $this->asFailure(t('Couldn’t reorder PDFs.', category: 'commerce')); } return $this->asSuccess(); diff --git a/src/Http/Controllers/Settings/ProductTypesController.php b/src/Http/Controllers/Settings/ProductTypesController.php index e23a685043..eb34b7fa42 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')); @@ -188,7 +577,7 @@ public function saveProductType(Request $request): ?Response return $this->asSuccess(t('Product type saved.', category: 'commerce')); } - return $this->asModelFailure($productType, t('Couldn\'t save product type.', category: 'commerce'), 'productType'); + return $this->asModelFailure($productType, t('Couldn’t save product type.', category: 'commerce'), 'productType'); } public function deleteProductType(Request $request): Response 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/ShippingCategoriesController.php b/src/Http/Controllers/Settings/ShippingCategoriesController.php deleted file mode 100644 index 9fe5bd4654..0000000000 --- a/src/Http/Controllers/Settings/ShippingCategoriesController.php +++ /dev/null @@ -1,262 +0,0 @@ -resolveStore($storeHandle); - $storeHandle = $store->handle; - - $shippingCategories = app(ShippingCategories::class)->getAllShippingCategories($store->id); - - $tableData = []; - foreach ($shippingCategories as $shippingCategory) { - $label = NewHtml::encode(t($shippingCategory->name, category: 'site')); - $tableData[] = [ - 'id' => $shippingCategory->id, - 'title' => $label, - 'chip' => Cp::chipHtml($shippingCategory, [ - 'labelHtml' => NewHtml::a($label, $shippingCategory->getCpEditUrl(), [ - 'class' => ['chip-label', 'cell-bold'], - ]), - ]), - 'url' => $shippingCategory->getCpEditUrl(), - 'handle' => $shippingCategory->handle, - 'description' => NewHtml::encode(t($shippingCategory->description, category: 'site')), - 'default' => $shippingCategory->default, - '_showDelete' => (count($shippingCategories) > 1 && !$shippingCategory->default), - ]; - } - - $tableData = Json::encode($tableData); - - $js = <<'; - } - } - }, - ]; - - new Craft.VueAdminTable({ - actions: [ - { - label: '', - icon: 'settings', - actions: [ - { - label: Craft.t('commerce', 'Set Default Category'), - action: 'commerce/shipping-categories/set-default-category', - param: 'storeHandle', - value: '{$storeHandle}', - allowMultiple: false - } - ] - } - ], - checkboxes: true, - columns: columns, - container: '#shipping-vue-admin-table', - deleteAction: 'commerce/shipping-categories/delete', - padded: true, - tableData: {$tableData}, - }); -JS; - - HtmlStack::js($js, Position::BodyEnd); - - return $this->storeManagementCpScreen($storeHandle) - ->additionalButtonsHtml(NewHtml::a( - t('New shipping category', category: 'commerce'), - $store->getStoreSettingsUrl('shippingcategories/new'), - ['class' => 'btn submit add icon'] - )) - ->contentHtml(NewHtml::tag('div', '', ['id' => 'shipping-vue-admin-table'])); - } - - public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse - { - $store = $this->resolveStore($storeHandle); - $storeHandle = $store->handle; - - if ($id) { - $shippingCategory = app(ShippingCategories::class)->getShippingCategoryById($id, $store->id); - abort_if($shippingCategory === null, 404); - } else { - $shippingCategory = new ShippingCategory(['storeId' => $store->id]); - } - - $title = $shippingCategory->id ? $shippingCategory->name : t('Create a new shipping category', category: 'commerce'); - - $productTypes = app(ProductTypes::class)->getAllProductTypes(); - $productTypesOptions = []; - if (!empty($productTypes)) { - $productTypesOptions = Arr::mapWithKeys($productTypes, fn($row) => [$row->id => ['label' => $row->name, 'value' => $row->id]]); - } - - $allShippingCategories = app(ShippingCategories::class)->getAllShippingCategories($store->id); - $isDefaultAndOnlyCategory = $id && $allShippingCategories->count() === 1 && $allShippingCategories->firstWhere('id', $id); - - $metaSidebar = ''; - if ($shippingCategory->id) { - $metaSidebar = Cp::metadataHtml([ - t('Created at') => I18N::getFormatter()->asDatetime($shippingCategory->dateCreated, 'short'), - t('Updated at') => I18N::getFormatter()->asDatetime($shippingCategory->dateUpdated, 'short'), - ]); - } - - return $this->storeManagementCpScreen($storeHandle, false) - ->title($title) - ->addCrumb(t('Shipping Categories', category: 'commerce'), $store->getStoreSettingsUrl('shippingcategories')) - ->action('commerce/shipping-categories/save') - ->redirectUrl($store->getStoreSettingsUrl('shippingcategories')) - ->metaSidebarHtml($metaSidebar) - ->contentTemplate('commerce/store-management/shipping/shippingcategories/_edit', [ - 'id' => $id, - 'shippingCategory' => $shippingCategory, - 'productTypes' => $productTypes, - 'storeHandle' => $storeHandle, - 'title' => $title, - 'productTypesOptions' => $productTypesOptions, - 'isDefaultAndOnlyCategory' => $isDefaultAndOnlyCategory, - ]); - } - - public function save(Request $request): Response - { - $shippingCategory = new ShippingCategory(); - - $shippingCategoryId = $request->input('shippingCategoryId'); - $shippingCategory->id = $shippingCategoryId ? (int)$shippingCategoryId : null; - $storeId = $request->input('storeId'); - $shippingCategory->storeId = $storeId ? (int)$storeId : null; - $this->requireStoreAccess($shippingCategory->storeId); - $shippingCategory->name = $request->input('name'); - $shippingCategory->handle = $request->input('handle'); - $shippingCategory->icon = $request->input('icon'); - $shippingCategory->color = $request->input('color'); - $shippingCategory->description = $request->input('description'); - $shippingCategory->default = (bool)$request->input('default'); - - if ($shippingCategory->default) { - $productTypes = app(ProductTypes::class)->getAllProductTypes(); - } else { - $postedProductTypes = $request->input('productTypes', []) ?: []; - $productTypes = []; - foreach ($postedProductTypes as $productTypeId) { - if ($productTypeId && $productType = app(ProductTypes::class)->getProductTypeById((int)$productTypeId)) { - $productTypes[] = $productType; - } - } - } - $shippingCategory->setProductTypes($productTypes); - - if (!app(ShippingCategories::class)->saveShippingCategory($shippingCategory)) { - return $this->asModelFailure( - $shippingCategory, - t('Couldn\'t save shipping category.', category: 'commerce'), - 'shippingCategory' - ); - } - - return $this->asModelSuccess( - $shippingCategory, - t('Shipping category saved.', category: 'commerce'), - 'shippingCategory', - data: [ - 'id' => $shippingCategory->id, - 'name' => $shippingCategory->name, - ] - ); - } - - public function delete(Request $request): Response - { - $id = $request->input('id'); - $ids = $request->input('ids'); - - abort_if((!$id && empty($ids)) || ($id && !empty($ids)), 400, 'id or ids must be specified.'); - - if ($id) { - abort_unless($request->expectsJson(), 400); - $ids = [$id]; - } - - $failedIds = []; - foreach ($ids as $deleteId) { - $shippingCategory = app(ShippingCategories::class)->getShippingCategoryById((int)$deleteId); - if ($shippingCategory) { - $this->requireStoreAccess($shippingCategory->storeId); - } - - if (!$shippingCategory || !app(ShippingCategories::class)->deleteShippingCategoryById((int)$deleteId)) { - $failedIds[] = $deleteId; - } - } - - if (!empty($failedIds)) { - return $this->asFailure(t('Could not delete {count, number} shipping {count, plural, one{category} other{categories}}.', [ - 'count' => count($failedIds), - ], category: 'commerce')); - } - - return $this->asSuccess(t('Shipping categories deleted.', category: 'commerce')); - } - - public function setDefaultCategory(Request $request): Response - { - $ids = $request->input('ids'); - $storeHandle = $request->input('storeHandle'); - $store = $storeHandle ? app(Stores::class)->getStoreByHandle($storeHandle) : null; - abort_if(!$storeHandle || $store === null, 400, 'Invalid store.'); - $this->requireStoreAccess($store->id); - - if (!empty($ids)) { - $id = Arr::first($ids); - - $shippingCategory = app(ShippingCategories::class)->getShippingCategoryById((int)$id, $store->id); - if ($shippingCategory) { - $shippingCategory->default = true; - if (app(ShippingCategories::class)->saveShippingCategory($shippingCategory)) { - return $this->asSuccess(t('Shipping category updated.', category: 'commerce')); - } - } - } - - return $this->asFailure(t('Unable to set default shipping category.', category: 'commerce')); - } -} diff --git a/src/Http/Controllers/Settings/ShippingMethodsController.php b/src/Http/Controllers/Settings/ShippingMethodsController.php deleted file mode 100644 index 0903adae36..0000000000 --- a/src/Http/Controllers/Settings/ShippingMethodsController.php +++ /dev/null @@ -1,235 +0,0 @@ -resolveStore($storeHandle); - - $shippingMethods = app(ShippingMethods::class)->getAllShippingMethods($store->id); - - $tableData = []; - foreach ($shippingMethods as $shippingMethod) { - $label = NewHtml::encode(t($shippingMethod->name, category: 'site')); - $tableData[] = [ - 'id' => $shippingMethod->id, - 'title' => $label, - 'chip' => Cp::chipHtml($shippingMethod, [ - 'showStatus' => true, - 'showThumb' => true, - 'labelHtml' => NewHtml::a($label, $shippingMethod->getCpEditUrl(), [ - 'class' => ['chip-label', 'cell-bold'], - ]), - ]), - 'url' => $shippingMethod->getCpEditUrl(), - 'handle' => $shippingMethod->handle, - 'type' => $shippingMethod->getType(), - 'status' => $shippingMethod->enabled, - ]; - } - - $tableData = Json::encode($tableData); - - $js = <<storeManagementCpScreen($storeHandle) - ->additionalButtonsHtml(NewHtml::a(t('New shipping method', category: 'commerce'), $store->getStoreSettingsUrl('shippingmethods/new'), ['class' => 'btn submit add icon'])) - ->contentHtml(NewHtml::tag('div', '', ['id' => 'shipping-vue-admin-table'])); - } - - public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse - { - $store = $this->resolveStore($storeHandle); - $storeHandle = $store->handle; - - if ($id) { - $shippingMethod = app(ShippingMethods::class)->getShippingMethodById($id, $store->id); - abort_if($shippingMethod === null, 404); - } else { - $shippingMethod = new ShippingMethod(['storeId' => $store->id]); - } - - $title = $shippingMethod->id ? $shippingMethod->name : t('Create a new shipping method', category: 'commerce'); - - $shippingRules = $shippingMethod->id !== null - ? app(ShippingRules::class)->getAllShippingRulesByShippingMethodId($shippingMethod->id) - : []; - - $metaDataHtml = NewHtml::beginTag('div', ['class' => 'meta']) . - Cp::lightswitchFieldHtml([ - 'label' => t('Enable this shipping method on the front end', category: 'commerce'), - 'id' => 'enabled', - 'name' => 'enabled', - 'on' => $shippingMethod->enabled, - 'errors' => $shippingMethod->getErrors('enabled'), - ]) . - NewHtml::endTag('div'); - - if ($shippingMethod->id) { - $metaDataHtml .= Cp::metadataHtml([ - t('Created at') => I18N::getFormatter()->asDatetime($shippingMethod->dateCreated, 'short'), - t('Updated at') => I18N::getFormatter()->asDatetime($shippingMethod->dateUpdated, 'short'), - ]); - } - - return $this->storeManagementCpScreen($storeHandle, false) - ->title($title) - ->action('commerce/shipping-methods/save') - ->redirectUrl($store->getStoreSettingsUrl('shippingmethods/{id}#rules')) - ->addCrumb(t('Shipping Methods', category: 'commerce'), $store->getStoreSettingsUrl('shippingmethods')) - ->metaSidebarHtml($metaDataHtml) - ->submitButtonLabel($shippingMethod->id ? t('Save and set rules', category: 'commerce') : t('Save')) - ->contentTemplate('commerce/store-management/shipping/shippingmethods/_edit', [ - 'shippingMethod' => $shippingMethod, - 'shippingRules' => $shippingRules, - 'store' => $store, - 'storeHandle' => $storeHandle, - 'orderConditionHtml' => new ConditionBuilderRenderer($shippingMethod->getOrderCondition())->render(), - 'customerConditionHtml' => new ConditionBuilderRenderer($shippingMethod->getCustomerCondition())->render(), - ]); - } - - public function save(Request $request): Response - { - $shippingMethod = new ShippingMethod(); - - $shippingMethodId = $request->input('shippingMethodId'); - $shippingMethod->id = $shippingMethodId ? (int)$shippingMethodId : null; - $shippingMethod->name = $request->input('name'); - $shippingMethod->handle = $request->input('handle'); - $shippingMethod->icon = $request->input('icon'); - $shippingMethod->color = $request->input('color'); - $storeId = $request->input('storeId'); - $shippingMethod->storeId = $storeId ? (int)$storeId : null; - $this->requireStoreAccess($shippingMethod->storeId); - $shippingMethod->setOrderCondition($request->input('orderCondition')); - $shippingMethod->setCustomerCondition($request->input('customerCondition')); - $shippingMethod->enabled = (bool)$request->input('enabled'); - - if (!app(ShippingMethods::class)->saveShippingMethod($shippingMethod)) { - return $this->asModelFailure($shippingMethod, t('Couldn\'t save shipping method.', category: 'commerce'), 'shippingMethod'); - } - - return $this->asModelSuccess($shippingMethod, t('Shipping method saved.', category: 'commerce'), 'shippingMethod'); - } - - public function delete(Request $request): Response - { - $id = $request->input('id'); - $ids = $request->input('ids'); - - abort_if((!$id && empty($ids)) || ($id && !empty($ids)), 400, 'id or ids must be specified.'); - - if ($id) { - abort_unless($request->expectsJson(), 400); - $ids = [$id]; - } - - $failedIds = []; - foreach ($ids as $deleteId) { - $shippingMethod = app(ShippingMethods::class)->getShippingMethodById((int)$deleteId); - if ($shippingMethod) { - $this->requireStoreAccess($shippingMethod->storeId); - } - - if (!$shippingMethod || !app(ShippingMethods::class)->deleteShippingMethodById((int)$deleteId)) { - $failedIds[] = $deleteId; - } - } - - if (!empty($failedIds)) { - return $this->asFailure(t('Could not delete {count, number} shipping {count, plural, one{method} other{methods}} and rules.', [ - 'count' => count($failedIds), - ], category: 'commerce')); - } - - return $this->asSuccess(t('Shipping methods and rules deleted.', category: 'commerce')); - } - - public function updateStatus(Request $request): Response - { - $ids = $request->input('ids'); - $status = $request->input('status'); - - abort_if(empty($ids), 400, 'Missing ids'); - - DB::transaction(function() use ($ids, $status) { - $shippingMethods = ShippingMethodRecord::whereIn('id', $ids)->get(); - - foreach ($shippingMethods as $shippingMethod) { - $this->requireStoreAccess($shippingMethod->storeId); - $shippingMethod->enabled = ($status == 'enabled'); - $shippingMethod->save(); - } - }); - - return $this->asSuccess(t('Shipping methods updated.', category: 'commerce')); - } -} diff --git a/src/Http/Controllers/Settings/ShippingRulesController.php b/src/Http/Controllers/Settings/ShippingRulesController.php deleted file mode 100644 index 67373b5f34..0000000000 --- a/src/Http/Controllers/Settings/ShippingRulesController.php +++ /dev/null @@ -1,205 +0,0 @@ -resolveStore($storeHandle); - - $shippingMethod = app(ShippingMethods::class)->getShippingMethodById($methodId, $store->id); - abort_if($shippingMethod === null, 404); - - if ($ruleId) { - $shippingRule = app(ShippingRules::class)->getShippingRuleById($ruleId); - abort_if($shippingRule === null, 404); - } else { - $shippingRule = new ShippingRule(); - $shippingRule->methodId = $shippingMethod->id; - $shippingRule->storeId = $shippingMethod->storeId; - } - - InputNamespace::set('new'); - HtmlStack::startJsBuffer(); - - $newZone = new ShippingAddressZone(); - $condition = $newZone->getCondition(); - $condition->mainTag = 'div'; - $condition->name = 'condition'; - $condition->id = 'condition'; - - $newShippingZoneFields = InputNamespace::namespaceInputs( - Template::renderTemplate('commerce/store-management/shipping/shippingzones/_fields', [ - 'conditionHtml' => new ConditionBuilderRenderer($condition)->render(), - ]) - ); - $newShippingZoneJs = HtmlStack::clearJsBuffer(false); - InputNamespace::set(null); - - $title = $ruleId ? $shippingRule->name : t('Create a new shipping rule', category: 'commerce'); - - $shippingZones = app(ShippingZones::class)->getAllShippingZones($store->id)->all(); - $shippingZoneOptions = []; - $shippingZoneOptions[] = t('Anywhere', category: 'commerce'); - foreach ($shippingZones as $model) { - $shippingZoneOptions[$model->id] = $model->name; - } - - $categoryShippingOptions = [ - ['label' => t('Allow', category: 'commerce'), 'value' => ShippingRuleCategoryRecord::CONDITION_ALLOW], - ['label' => t('Disallow', category: 'commerce'), 'value' => ShippingRuleCategoryRecord::CONDITION_DISALLOW], - ['label' => t('Require', category: 'commerce'), 'value' => ShippingRuleCategoryRecord::CONDITION_REQUIRE], - ]; - - return pageTemplate('commerce/store-management/shipping/shippingrules/_edit', [ - 'methodId' => $methodId, - 'ruleId' => $ruleId, - 'shippingRule' => $shippingRule, - 'shippingMethod' => $shippingMethod, - 'newShippingZoneFields' => $newShippingZoneFields, - 'newShippingZoneJs' => $newShippingZoneJs, - 'title' => $title, - 'shippingZones' => $shippingZoneOptions, - 'categoryShippingOptions' => $categoryShippingOptions, - 'storeId' => $store->id, - 'storeHandle' => $store->handle, - 'storeSwitcher' => $this->getStoreSwitcher($store->handle), - 'orderConditionHtml' => new ConditionBuilderRenderer($shippingRule->getOrderCondition())->render(), - 'customerConditionHtml' => new ConditionBuilderRenderer($shippingRule->getCustomerCondition())->render(), - ], TemplateMode::Cp); - } - - public function duplicate(Request $request): Response - { - return $this->save($request, duplicate: true); - } - - public function save(Request $request, bool $duplicate = false): Response - { - $shippingRule = new ShippingRule(); - - if (!$duplicate) { - $shippingRule->id = $request->input('id') ? (int)$request->input('id') : null; - } - $shippingRule->storeId = $request->input('storeId') ? (int)$request->input('storeId') : null; - - $moneyInputs = [ - 'baseRate', - 'maxRate', - 'minRate', - 'perItemRate', - 'weightRate', - ]; - - foreach ($moneyInputs as $moneyInput) { - $input = $request->input($moneyInput); - $input += [ - 'currency' => $shippingRule->getStore()->getCurrency(), - ]; - $shippingRule->$moneyInput = (float)Money::toDecimal(Money::toMoney($input)); - } - - $shippingRule->name = $request->input('name'); - $shippingRule->description = $request->input('description'); - $shippingRule->methodId = $request->input('methodId') ? (int)$request->input('methodId') : null; - $shippingRule->enabled = (bool)$request->input('enabled'); - $shippingRule->orderConditionFormula = trim((string)$request->input('orderConditionFormula', '')); - $shippingRule->percentageRate = (float)Localization::normalizeNumber($request->input('percentageRate')); - $shippingRule->setOrderCondition($request->input('orderCondition')); - $shippingRule->setCustomerCondition($request->input('customerCondition')); - - $ruleCategories = []; - $allRulesCategories = $request->input('ruleCategories'); - foreach ($allRulesCategories as $key => $ruleCategory) { - $perItemRate = $ruleCategory['perItemRate']; - $weightRate = $ruleCategory['weightRate']; - $percentageRate = $ruleCategory['percentageRate']; - $ruleCategory['perItemRate'] = (!isset($perItemRate) || trim((string)$perItemRate['value']) === '') - ? null - : Money::toDecimal(Money::toMoney(array_merge([ - 'currency' => $shippingRule->getStore()->getCurrency(), - ], $perItemRate))); - $ruleCategory['weightRate'] = (!isset($weightRate) || trim((string)$weightRate['value']) === '') - ? null - : Money::toDecimal(Money::toMoney(array_merge([ - 'currency' => $shippingRule->getStore()->getCurrency(), - ], $weightRate))); - $ruleCategory['percentageRate'] = (!isset($percentageRate) || trim((string)$percentageRate) === '') ? null : Localization::normalizeNumber($percentageRate); - - $ruleCategories[$key] = new ShippingRuleCategory($ruleCategory); - $ruleCategories[$key]->shippingCategoryId = $key; - } - - $shippingRule->setShippingRuleCategories($ruleCategories); - - if (!app(ShippingRules::class)->saveShippingRule($shippingRule)) { - return $this->asModelFailure($shippingRule, t('Couldn\'t save shipping rule.', category: 'commerce'), 'shippingRule'); - } - - return $this->asModelSuccess($shippingRule, t('Shipping rule saved.', category: 'commerce'), 'shippingRule'); - } - - public function reorder(Request $request): Response - { - abort_unless($request->expectsJson(), 400); - abort_unless($request->input('ids'), 400, 'Missing ids'); - - $ids = Json::decode($request->input('ids')); - app(ShippingRules::class)->reorderShippingRules($ids); - - return $this->asSuccess(); - } - - public function delete(Request $request): Response - { - if ($request->ajax()) { - abort_unless($request->expectsJson(), 400); - } - - $id = $request->input('id'); - abort_if(!$id, 400, 'Shipping rule ID not submitted'); - $id = (int)$id; - - $rule = app(ShippingRules::class)->getShippingRuleById($id); - abort_if($rule === null, 400, 'Cannot find shipping rule to delete'); - - if (!app(ShippingRules::class)->deleteShippingRuleById($id)) { - return $this->asFailure(t('Could not delete shipping rule', category: 'commerce')); - } - - if ($request->ajax()) { - return $this->asSuccess(); - } - - return $this->redirectToPostedUrl($rule); - } -} diff --git a/src/Http/Controllers/Settings/ShippingZonesController.php b/src/Http/Controllers/Settings/ShippingZonesController.php deleted file mode 100644 index e9b436386d..0000000000 --- a/src/Http/Controllers/Settings/ShippingZonesController.php +++ /dev/null @@ -1,173 +0,0 @@ -resolveStore($storeHandle); - - $shippingZones = app(ShippingZones::class)->getAllShippingZones($store->id); - - $tableData = []; - foreach ($shippingZones as $shippingZone) { - $label = NewHtml::encode(t($shippingZone->name, category: 'site')); - $tableData[] = [ - 'id' => $shippingZone->id, - 'title' => NewHtml::a($label, $shippingZone->getCpEditUrl()), - 'url' => $shippingZone->getCpEditUrl(), - 'description' => NewHtml::encode(t($shippingZone->description, category: 'site')), - ]; - } - - $tableData = Json::encode($tableData); - - $js = <<storeManagementCpScreen($storeHandle) - ->additionalButtonsHtml(NewHtml::a(t('New shipping zone', category: 'commerce'), $store->getStoreSettingsUrl('shippingzones/new'), ['class' => 'btn submit add icon'])) - ->contentHtml(NewHtml::tag('div', '', ['id' => 'shipping-vue-admin-table'])); - } - - public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse - { - $store = $this->resolveStore($storeHandle); - $storeHandle = $store->handle; - - if ($id) { - $shippingZone = app(ShippingZones::class)->getShippingZoneById($id, $store->id); - abort_if($shippingZone === null, 404); - } else { - $shippingZone = new ShippingAddressZone(['storeId' => $store->id]); - } - - $title = $shippingZone->id ? $shippingZone->name : t('Create a shipping zone', category: 'commerce'); - - $condition = $shippingZone->getCondition(); - $condition->mainTag = 'div'; - $condition->name = 'condition'; - $condition->id = 'condition'; - - $metadata = []; - if ($shippingZone->id) { - $metadata = [ - t('Created at') => I18N::getFormatter()->asDatetime($shippingZone->dateCreated, 'short'), - t('Updated at') => I18N::getFormatter()->asDatetime($shippingZone->dateUpdated, 'short'), - ]; - } - - return $this->storeManagementCpScreen($storeHandle, false) - ->title($title) - ->addCrumb(t('Shipping Zones', category: 'commerce'), $store->getStoreSettingsUrl('shippingzones')) - ->action('commerce/shipping-zones/save') - ->redirectUrl($store->getStoreSettingsUrl('shippingzones')) - ->metaSidebarHtml(\craft\helpers\Cp::metadataHtml($metadata)) - ->contentTemplate('commerce/store-management/shipping/shippingzones/_edit', [ - 'shippingZone' => $shippingZone, - 'conditionHtml' => new ConditionBuilderRenderer($condition)->render(), - 'store' => $store, - ]); - } - - public function save(Request $request): Response - { - $shippingZone = new ShippingAddressZone(); - - $shippingZone->id = $request->input('shippingZoneId') ? (int)$request->input('shippingZoneId') : null; - $shippingZone->storeId = $request->input('storeId') ? (int)$request->input('storeId') : null; - $this->requireStoreAccess($shippingZone->storeId); - $shippingZone->name = $request->input('name'); - $shippingZone->description = $request->input('description'); - $shippingZone->setCondition($request->input('condition')); - - if ($shippingZone->validate() && app(ShippingZones::class)->saveShippingZone($shippingZone)) { - return $this->asModelSuccess( - $shippingZone, - t('Shipping zone saved.', category: 'commerce'), - 'shippingZone', - data: [ - 'id' => $shippingZone->id, - 'name' => $shippingZone->name, - ] - ); - } - - return $this->asModelFailure( - $shippingZone, - t('Couldn\'t save shipping zone.', category: 'commerce'), - 'shippingZone' - ); - } - - public function delete(Request $request): Response - { - abort_unless($request->expectsJson(), 400); - - $id = $request->input('id'); - abort_if(!$id, 400, 'Missing shipping zone id'); - - $shippingZone = app(ShippingZones::class)->getShippingZoneById((int)$id); - if ($shippingZone) { - $this->requireStoreAccess($shippingZone->storeId); - } - - if (!app(ShippingZones::class)->deleteShippingZoneById((int)$id)) { - return $this->asFailure(t('Could not delete shipping zone', category: 'commerce')); - } - - return $this->asSuccess(); - } - - public function testZip(Request $request): Response - { - abort_unless($request->expectsJson(), 400); - - $zipCodeFormula = (string)$request->input('zipCodeConditionFormula'); - $testZipCode = (string)$request->input('testZipCode'); - - $params = ['zipCode' => $testZipCode]; - - if (!app(Formulas::class)->evaluateCondition($zipCodeFormula, $params)) { - return $this->asFailure('failed'); - } - - return $this->asSuccess(); - } -} diff --git a/src/Http/Controllers/Settings/StoreManagementController.php b/src/Http/Controllers/Settings/StoreManagementController.php deleted file mode 100644 index e3814bc9cc..0000000000 --- a/src/Http/Controllers/Settings/StoreManagementController.php +++ /dev/null @@ -1,212 +0,0 @@ -getStore(); - - if (currentUser()?->can('commerce-manageGeneralStoreSettings')) { - return redirect($store->getStoreSettingsUrl()); - } - - if (currentUser()?->can('commerce-managePaymentCurrencies')) { - return redirect($store->getStoreSettingsUrl('payment-currencies')); - } - - if (currentUser()?->can('commerce-managePromotions')) { - return redirect($store->getStoreSettingsUrl('discounts')); - } - - if (currentUser()?->can('commerce-manageShipping')) { - return redirect($store->getStoreSettingsUrl('shipping')); - } - - if (currentUser()?->can('commerce-manageTaxes')) { - return redirect($store->getStoreSettingsUrl('taxrates')); - } - - return $this->storeManagementCpScreen($store->handle) - ->contentHtml(Html::tag( - 'p', - t('No access given to any specific store management features.', category: 'commerce') - )); - } - - public function edit(?string $storeHandle = null): Response|CpScreenResponse - { - abort_unless(currentUser()?->can('commerce-manageGeneralStoreSettings'), 403); - - if ($storeHandle) { - $store = app(Stores::class)->getStoreByHandle($storeHandle); - abort_if($store === null, 404); - $storeSettings = $store->getSettings(); - } else { - $site = Cp::requestedSite(); - /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ - return redirect($site->getStore()->getStoreSettingsUrl()); - } - - $allCountries = Addresses::getCountryRepository()->getList(\Craft::$app->language); - - $locationFieldHtml = Cp::elementCardHtml($storeSettings->getLocationAddress(), [ - 'context' => 'field', - 'inputName' => 'locationAddressId', - 'showActionMenu' => true, - ]); - - $condition = $storeSettings->getMarketAddressCondition(); - $condition->mainTag = 'div'; - $condition->name = 'marketAddressCondition'; - $condition->id = 'marketAddressCondition'; - $marketAddressConditionFieldHtml = Cp::fieldHtml(new ConditionBuilderRenderer($condition)->render(), [ - 'label' => t('Order Address Condition'), - 'instructions' => t('Only allow orders with addresses that match the following rules:'), - ]); - - $countriesField = Cp::selectizeFieldHtml([ - 'label' => t('Country List', category: 'commerce'), - 'instructions' => t('The countries that orders are allowed to be placed from.', category: 'commerce'), - 'id' => 'countries', - 'name' => 'countries', - 'multi' => true, - 'values' => $storeSettings->getCountries(), - 'options' => $allCountries, - 'errors' => $storeSettings->getErrors('countries'), - 'allowEmptyOption' => true, - ]); - - $inventoryLocations = app(InventoryLocations::class)->getInventoryLocations($store->id); - $allInventoryLocations = app(InventoryLocations::class)->getAllInventoryLocations(); - $currentUser = currentUserElement(); - - $locationsCount = count($allInventoryLocations); - $userCanCreate = $currentUser?->can('commerce-manageInventoryLocations'); - $inventoryLocationsField = ''; - - if ($userCanCreate) { - $canCreate = false; - - $limit = Plugin::EDITION_PRO_STORE_LIMIT; - if ($locationsCount < $limit) { - $canCreate = true; - } - - if ($this->plugin->is(Plugin::EDITION_ENTERPRISE, '=')) { - $limit = null; - $canCreate = true; - } - - $config = [ - 'label' => t('Inventory Locations', category: 'commerce'), - 'instructions' => t('The inventory locations this store uses.', category: 'commerce'), - 'id' => 'inventoryLocations', - 'name' => 'inventoryLocations[]', - 'values' => $inventoryLocations, - 'create' => $canCreate, - ]; - - if ($limit !== null) { - $config['limit'] = $limit; - } - - $inventoryLocationsField = CommerceCp::inventoryLocationFieldHtml($config); - } - - return $this->storeManagementCpScreen($storeHandle) - ->action('commerce/store-management/save') - ->redirectUrl($store->getStoreSettingsUrl()) - ->submitButtonLabel(t('Save')) - ->contentTemplate('commerce/store-management/general/_edit', [ - 'store' => $store, - 'storeHandle' => $storeHandle, - 'storeSettings' => $storeSettings, - 'marketAddressConditionField' => $marketAddressConditionFieldHtml, - 'countriesField' => $countriesField, - 'locationField' => $locationFieldHtml, - 'inventoryLocationsField' => $inventoryLocationsField, - ]); - } - - public function save(Request $request): Response - { - abort_unless(currentUser()?->can('commerce-manageGeneralStoreSettings'), 403); - - $storeId = (int)$request->input('id'); - $this->requireStoreAccess($storeId); - $store = app(Stores::class)->getStoreById($storeId); - $storeSettings = app(StoreSettings::class)->getStoreSettingsById($storeId); - $currentUser = currentUserElement(); - - if ($locationAddressId = $request->input('locationAddressId')) { - $locationAddress = Address::find()->id($locationAddressId)->one(); - if ($locationAddress) { - $storeSettings->setLocationAddress($locationAddress); - } - } - $marketAddressCondition = $request->input('marketAddressCondition') ?? new ZoneAddressCondition(); - $storeSettings->setMarketAddressCondition($marketAddressCondition); - $countries = $request->input('countries') ?: []; - $storeSettings->setCountries($countries); - - if ($currentUser?->can('commerce-manageInventoryLocations')) { - $inventoryLocations = $request->input('inventoryLocations'); - - if (!$inventoryLocations) { - return $this->asFailure(t('Missing a default inventory location.', category: 'commerce')); - } - - if (!app(InventoryLocations::class)->saveStoreInventoryLocations($store, $inventoryLocations)) { - return $this->asFailure(t('Inventory locations not saved.', category: 'commerce')); - } - } - - if (!$storeSettings->validate() || !app(StoreSettings::class)->saveStoreSettings($storeSettings)) { - return $this->asModelFailure( - model: $storeSettings, - message: t('Couldn\'t save store.', category: 'commerce'), - modelName: 'storeSettings', - ); - } - - return $this->asModelSuccess( - model: $storeSettings, - message: t('Store saved.', category: 'commerce'), - modelName: 'storeSettings', - ); - } -} diff --git a/src/Http/Controllers/Settings/StoresController.php b/src/Http/Controllers/Settings/StoresController.php index 39b96a69c2..571ad82736 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,11 +260,11 @@ 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)) { - return $this->asModelFailure($store, t('Couldn\'t save the store.'), 'store'); + return $this->asModelFailure($store, t('Couldn’t save the store.'), 'store'); } if ($siteId = $request->input('siteId')) { @@ -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 @@ -208,26 +383,60 @@ public function reorderStores(Request $request): Response $ids = Json::decode($request->input('ids')); if (!app(Stores::class)->reorderStores($ids)) { - return $this->asFailure(t('Couldn\'t reorder stores.', category: 'commerce')); + return $this->asFailure(t('Couldn’t reorder stores.', category: 'commerce')); } 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/TaxCategoriesController.php b/src/Http/Controllers/Settings/TaxCategoriesController.php deleted file mode 100644 index 8a98bb00a2..0000000000 --- a/src/Http/Controllers/Settings/TaxCategoriesController.php +++ /dev/null @@ -1,252 +0,0 @@ -resolveStore($storeHandle); - - $taxCategories = app(TaxCategories::class)->getAllTaxCategories(); - - $tableData = []; - foreach ($taxCategories as $taxCategory) { - $label = NewHtml::encode(t($taxCategory->name, category: 'site')); - $taxRates = $taxCategory->getTaxRates($store->id); - $tableData[] = [ - 'id' => $taxCategory->id, - 'title' => $label, - 'chip' => Cp::chipHtml($taxCategory, [ - 'labelHtml' => NewHtml::a($label, $taxCategory->getCpEditUrl($store->id), [ - 'class' => ['chip-label', 'cell-bold'], - ]), - ]), - 'url' => $taxCategory->getCpEditUrl($store->id), - 'handle' => $taxCategory->handle, - 'description' => NewHtml::encode(t($taxCategory->description, category: 'site')), - 'default' => $taxCategory->default, - '_showDelete' => $taxRates->isEmpty() && (count($taxCategories) > 1 && !$taxCategory->default), - ]; - } - - $buttons = app(Taxes::class)->taxCategoryActionHtml(); - if (app(Taxes::class)->createTaxCategories()) { - $buttons .= NewHtml::a(t('New tax category', category: 'commerce'), $store->getStoreSettingsUrl('taxcategories/new'), [ - 'class' => ['btn', 'submit', 'add', 'icon'], - ]); - } - - $tableData = Json::encode($tableData); - $deleteAction = app(Taxes::class)->deleteTaxCategories() ? "'commerce/tax-categories/delete'" : 'null'; - - $js = <<'; - } - } - }, - ]; - - var actions = [ - { - label: '', - icon: 'settings', - actions: [ - { - label: Craft.t('commerce', 'Set default category'), - action: 'commerce/tax-categories/set-default-category', - param: 'default', - value: 1, - allowMultiple: false - } - ] - } - ]; - - new Craft.VueAdminTable({ - columns: columns, - checkboxes: true, - actions: actions, - padded: true, - container: '#tax-vue-admin-table', - deleteAction: {$deleteAction}, - tableData: {$tableData}, - }); -JS; - - HtmlStack::js($js, Position::BodyEnd); - - return $this->storeManagementCpScreen($storeHandle, hasStoreSwitcher: false) - ->additionalButtonsHtml($buttons) - ->contentHtml(NewHtml::tag('div', '', ['id' => 'tax-vue-admin-table'])); - } - - public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse - { - $store = $this->resolveStore($storeHandle); - $storeHandle = $store->handle; - - $productTypes = app(ProductTypes::class)->getAllProductTypes(); - - if ($id) { - $taxCategory = app(TaxCategories::class)->getTaxCategoryById($id); - abort_if($taxCategory === null, 404); - } else { - $taxCategory = new TaxCategory(); - } - - $title = $taxCategory->id ? $taxCategory->name : t('Create a new tax category', category: 'commerce'); - - $productTypesOptions = []; - if (!empty($productTypes)) { - $productTypesOptions = Arr::mapWithKeys($productTypes, fn($row) => [$row->id => ['label' => $row->name, 'value' => $row->id]]); - } - - $allTaxCategoryIds = array_keys(app(TaxCategories::class)->getAllTaxCategories()); - $isDefaultAndOnlyCategory = $id && count($allTaxCategoryIds) === 1 && in_array($id, $allTaxCategoryIds); - - $taxRates = collect(); - app(Stores::class)->getAllStores()->each(fn(Store $s) => $taxRates->push(...app(TaxRates::class)->getAllTaxRates($s->id)->all())); - - $metaSidebar = ''; - if ($taxCategory->id) { - $metaSidebar = Cp::metadataHtml([ - t('Created at') => I18N::getFormatter()->asDatetime($taxCategory->dateCreated, 'short'), - t('Updated at') => I18N::getFormatter()->asDatetime($taxCategory->dateUpdated, 'short'), - ]); - } - - return $this->storeManagementCpScreen($storeHandle, false, false) - ->title($title) - ->addCrumb(t('Tax Categories', category: 'commerce'), $store->getStoreSettingsUrl('taxcategories')) - ->action('commerce/tax-categories/save') - ->redirectUrl($store->getStoreSettingsUrl('taxcategories')) - ->metaSidebarHtml($metaSidebar) - ->contentTemplate('commerce/store-management/tax/taxcategories/_edit', [ - 'taxCategory' => $taxCategory, - 'productTypes' => $productTypes, - 'productTypesOptions' => $productTypesOptions, - 'isDefaultAndOnlyCategory' => $isDefaultAndOnlyCategory, - 'taxRates' => $taxRates, - 'store' => $store, - ]); - } - - public function save(Request $request): Response - { - $taxCategory = new TaxCategory(); - - $taxCategory->id = $request->input('taxCategoryId') ? (int)$request->input('taxCategoryId') : null; - $taxCategory->name = $request->input('name'); - $taxCategory->handle = $request->input('handle'); - $taxCategory->icon = $request->input('icon'); - $taxCategory->color = $request->input('color'); - $taxCategory->description = $request->input('description'); - $taxCategory->default = (bool)$request->input('default'); - - $postedProductTypes = $request->input('productTypes', []) ?: []; - $productTypes = []; - foreach ($postedProductTypes as $productTypeId) { - if ($productTypeId && $productType = app(ProductTypes::class)->getProductTypeById((int)$productTypeId)) { - $productTypes[] = $productType; - } - } - $taxCategory->setProductTypes($productTypes); - - if (!app(TaxCategories::class)->saveTaxCategory($taxCategory)) { - return $this->asModelFailure( - $taxCategory, - t('Couldn\'t save tax category.', category: 'commerce'), - 'taxCategory' - ); - } - - return $this->asModelSuccess( - $taxCategory, - t('Tax category saved.', category: 'commerce'), - 'taxCategory' - ); - } - - public function delete(Request $request): Response - { - $id = $request->input('id'); - $ids = $request->input('ids'); - - abort_if((!$id && empty($ids)) || ($id && !empty($ids)), 400, 'id or ids must be specified.'); - - if ($id) { - abort_unless($request->expectsJson(), 400); - $ids = [$id]; - } - - $failedIds = []; - foreach ($ids as $deleteId) { - if (!app(TaxCategories::class)->deleteTaxCategoryById((int)$deleteId)) { - $failedIds[] = $deleteId; - } - } - - if (!empty($failedIds)) { - return $this->asFailure(t('Could not delete {count, number} tax {count, plural, one{category} other{categories}}.', [ - 'count' => count($failedIds), - ], category: 'commerce')); - } - - return $this->asSuccess(t('Tax categories deleted.', category: 'commerce')); - } - - public function setDefaultCategory(Request $request): Response - { - $ids = $request->input('ids'); - abort_if(empty($ids), 400, 'Missing ids'); - - $id = Arr::first($ids); - - $taxCategory = app(TaxCategories::class)->getTaxCategoryById((int)$id); - if ($taxCategory) { - $taxCategory->default = true; - if (app(TaxCategories::class)->saveTaxCategory($taxCategory)) { - return $this->asSuccess(t('Tax category updated.', category: 'commerce')); - } - } - - return $this->asFailure(t('Unable to set default tax category.', category: 'commerce')); - } -} diff --git a/src/Http/Controllers/Settings/TaxRatesController.php b/src/Http/Controllers/Settings/TaxRatesController.php deleted file mode 100644 index e057e7ac3c..0000000000 --- a/src/Http/Controllers/Settings/TaxRatesController.php +++ /dev/null @@ -1,284 +0,0 @@ -resolveStore($storeHandle); - $storeHandle = $store->handle; - - $taxRates = app(TaxRates::class)->getAllTaxRates($store->id); - - // Preload all zone and category data for listing. - app(TaxZones::class)->getAllTaxZones($store->id); - app(TaxCategories::class)->getAllTaxCategories(); - - $tableData = []; - foreach ($taxRates as $taxRate) { - $label = NewHtml::encode(t($taxRate->name, category: 'site')); - $tableData[] = [ - 'id' => $taxRate->id, - 'status' => $taxRate->enabled, - 'title' => NewHtml::a($label, $taxRate->getCpEditUrl()), - 'url' => $taxRate->getCpEditUrl(), - 'rate' => $taxRate->getRateAsPercent(), - 'included' => $taxRate->include, - 'removeIncluded' => $taxRate->removeIncluded, - 'vat' => $taxRate->hasTaxIdValidators(), - 'zone' => $taxRate->getIsEverywhere() ? t('Everywhere', category: 'commerce') : ($taxRate->getTaxZone() ? NewHtml::encode($taxRate->getTaxZone()->name) : ''), - 'category' => $taxRate->getTaxCategory() ? Cp::chipHtml($taxRate->getTaxCategory()) : '', - ]; - } - - $buttonsHtml = app(Taxes::class)->taxRateActionHtml(); - - if (app(Taxes::class)->createTaxRates()) { - $buttonsHtml .= NewHtml::a(t('New tax rate', category: 'commerce'), "commerce/store-management/$storeHandle/taxrates/new", [ - 'class' => 'btn submit add icon', - ]); - } - - $tableData = Json::encode($tableData, JSON_UNESCAPED_UNICODE); - $deleteAction = app(Taxes::class)->deleteTaxRates() ? 'commerce/tax-rates/delete' : null; - - $js = <<'; - } - } }, - { name: 'removeIncluded', title: Craft.t('commerce', 'Remove from price?'), callback: function(value) { - if (value) { - return ''; - } - } }, - { name: 'zone', title: Craft.t('commerce', 'Tax Zone') }, - { name: 'category', title: Craft.t('commerce', 'Tax Category') } -]; - -var actions = [ - { - label: Craft.t('commerce', 'Set status'), - actions: [ - { - label: Craft.t('commerce', 'Enabled'), - action: 'commerce/tax-rates/update-status', - param: 'status', - value: 'enabled', - status: 'enabled' - }, - { - label: Craft.t('commerce', 'Disabled'), - action: 'commerce/tax-rates/update-status', - param: 'status', - value: 'disabled', - status: 'disabled' - } - ] - } -]; - -new Craft.VueAdminTable({ - columns: columns, - actions: actions, - checkboxes: true, - container: '#taxrate-vue-admin-table', - deleteAction: '{$deleteAction}', - tableData: {$tableData}, -}); -JS; - - HtmlStack::js($js, Position::BodyEnd); - - return $this->storeManagementCpScreen($storeHandle) - ->additionalButtonsHtml($buttonsHtml) - ->contentHtml(NewHtml::tag('div', '', ['id' => 'taxrate-vue-admin-table'])); - } - - public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse - { - abort_unless(app(Taxes::class)->viewTaxRates(), 403, 'Tax engine does not permit you to perform this action'); - - $store = $this->resolveStore($storeHandle); - $storeHandle = $store->handle; - $percentSymbol = I18N::getFormattingLocale()->getNumberSymbol(Locale::SYMBOL_PERCENT); - - - if ($id) { - $taxRate = app(TaxRates::class)->getTaxRateById($id, $store->id); - abort_if($taxRate === null, 404); - } else { - $taxRate = new TaxRate(['storeId' => $store->id]); - } - - $title = $taxRate->id ? $taxRate->name : t('Create a new tax rate', category: 'commerce'); - - $variables = compact('taxRate', 'store', 'storeHandle', 'percentSymbol'); - - $taxZone = null; - if ($taxRate->taxZoneId) { - $taxZone = app(TaxZones::class)->getTaxZoneById($taxRate->taxZoneId, $store->id); - } - - $taxCategory = null; - if ($taxRate->taxCategoryId) { - $taxCategory = app(TaxCategories::class)->getTaxCategoryById($taxRate->taxCategoryId); - } - - $variables['taxZoneField'] = CommerceCp::taxZoneFieldHtml([ - 'label' => t('Tax Zone', category: 'commerce'), - 'instructions' => t('Select a tax zone. If empty, this rate will match anywhere.', category: 'commerce'), - 'id' => 'taxZoneId', - 'name' => 'taxZoneId', - 'value' => $taxZone, - 'errors' => $taxRate->getErrors('taxZoneId'), - 'required' => false, - 'limit' => 1, - 'storeId' => $store->id, - 'storeHandle' => $storeHandle, - ]); - - $variables['taxCategoryField'] = CommerceCp::taxCategoryFieldHtml([ - 'label' => t('Tax Category', category: 'commerce'), - 'instructions' => t('Select a tax category.', category: 'commerce'), - 'id' => 'taxCategoryId', - 'name' => 'taxCategoryId', - 'value' => $taxCategory, - 'errors' => $taxRate->getErrors('taxCategoryId'), - 'required' => true, - 'limit' => 1, - 'storeHandle' => $storeHandle, - ]); - - $taxable = []; - $taxable[TaxRateRecord::TAXABLE_PURCHASABLE] = t('Unit price (minus discounts)', category: 'commerce'); - $taxable[TaxRateRecord::TAXABLE_PRICE] = t('Line item price (minus discounts)', category: 'commerce'); - $taxable[TaxRateRecord::TAXABLE_SHIPPING] = t('Line item shipping cost', category: 'commerce'); - $taxable[TaxRateRecord::TAXABLE_PRICE_SHIPPING] = t('Both (Line item price + Line item shipping costs)', category: 'commerce'); - $taxable[TaxRateRecord::TAXABLE_ORDER_TOTAL_SHIPPING] = t('Order total shipping cost', category: 'commerce'); - $taxable[TaxRateRecord::TAXABLE_ORDER_TOTAL_PRICE] = t('Order total taxable price (Line item subtotal + Total discounts + Total shipping)', category: 'commerce'); - $variables['taxables'] = $taxable; - $variables['taxablesNoTaxCategory'] = TaxRateRecord::ORDER_TAXABALES; - - $variables['hideTaxCategory'] = false; - if ($variables['taxRate']->id && in_array($variables['taxRate']->taxable, $variables['taxablesNoTaxCategory'], false)) { - $variables['hideTaxCategory'] = true; - } - - $variables['taxIdValidators'] = []; - $taxIdValidators = app(Taxes::class)->getEnabledTaxIdValidators(); - foreach ($taxIdValidators as $validator) { - $variables['taxIdValidators'][] = $validator; - } - - return $this->storeManagementCpScreen($storeHandle, false) - ->title($title) - ->addCrumb(t('Tax Rates', category: 'commerce'), $store->getStoreSettingsUrl('taxrates')) - ->selectedSubnavItem('store-management') - ->action('commerce/tax-rates/save') - ->redirectUrl($store->getStoreSettingsUrl('taxrates')) - ->metaSidebarTemplate('commerce/store-management/tax/taxrates/_sidebar', $variables) - ->contentTemplate('commerce/store-management/tax/taxrates/_edit', $variables); - } - - public function save(Request $request): Response - { - abort_unless(app(Taxes::class)->editTaxRates(), 403, 'Tax engine does not permit you to perform this action'); - - $taxRate = new TaxRate(); - - $taxRate->id = $request->input('taxRateId') ? (int)$request->input('taxRateId') : null; - $taxRate->storeId = $request->input('storeId') ? (int)$request->input('storeId') : null; - $this->requireStoreAccess($taxRate->storeId); - $taxRate->name = $request->input('name'); - $taxRate->code = $request->input('code'); - $taxRate->include = (bool)$request->input('include'); - $taxRate->removeIncluded = (bool)$request->input('removeIncluded'); - $taxRate->removeVatIncluded = (bool)$request->input('removeVatIncluded'); - $taxRate->taxable = $request->input('taxable'); - $taxRate->taxCategoryId = (int)$request->input('taxCategoryId') ?: null; - $taxRate->taxZoneId = (int)$request->input('taxZoneId') ?: null; - $taxRate->rate = Localization::normalizePercentage($request->input('rate')); - $taxRate->enabled = (bool)$request->input('enabled'); - - $validators = collect($request->input('taxIdValidators'))->filter(fn($enabled) => (bool)$enabled)->keys(); - $taxRate->taxIdValidators = $validators->toArray(); - - if (!app(TaxRates::class)->saveTaxRate($taxRate)) { - return $this->asModelFailure($taxRate, t('Couldn\'t save tax rate.', category: 'commerce'), 'taxRate'); - } - - return $this->asModelSuccess($taxRate, t('Tax rate saved.', category: 'commerce'), 'taxRate'); - } - - public function delete(Request $request): Response - { - abort_unless(app(Taxes::class)->deleteTaxRates(), 403, 'Tax engine does not permit you to perform this action'); - abort_unless($request->expectsJson(), 400); - - $id = $request->input('id'); - abort_if(!$id, 400, 'Missing tax rate id'); - - $taxRate = app(TaxRates::class)->getTaxRateById((int)$id); - if ($taxRate) { - $this->requireStoreAccess($taxRate->storeId); - } - - app(TaxRates::class)->deleteTaxRateById((int)$id); - return $this->asSuccess(); - } - - public function updateStatus(Request $request): Response - { - $ids = $request->input('ids'); - $status = $request->input('status'); - - abort_if(empty($ids), 400, 'Missing ids'); - - DB::transaction(function() use ($ids, $status) { - $taxRates = TaxRateRecord::whereIn('id', $ids)->get(); - - foreach ($taxRates as $taxRate) { - $this->requireStoreAccess($taxRate->storeId); - $taxRate->enabled = ($status == 'enabled'); - $taxRate->save(); - } - }); - - return $this->asSuccess(t('Tax rates updated.', category: 'commerce')); - } -} diff --git a/src/Http/Controllers/Settings/TaxZonesController.php b/src/Http/Controllers/Settings/TaxZonesController.php deleted file mode 100644 index 80295db48f..0000000000 --- a/src/Http/Controllers/Settings/TaxZonesController.php +++ /dev/null @@ -1,181 +0,0 @@ -resolveStore($storeHandle); - - $taxZones = app(TaxZones::class)->getAllTaxZones($store->id); - - $tableData = []; - foreach ($taxZones as $taxZone) { - $label = NewHtml::encode(t($taxZone->name, category: 'site')); - $tableData[] = [ - 'id' => $taxZone->id, - 'title' => NewHtml::a($label, $taxZone->getCpEditUrl()), - 'url' => $taxZone->getCpEditUrl(), - 'description' => NewHtml::encode(t($taxZone->description, category: 'site')), - 'default' => $taxZone->default, - ]; - } - - $tableData = Json::encode($tableData); - - $js = <<'; - } - } - }, -]; - -new Craft.VueAdminTable({ - columns: columns, - container: '#tax-vue-admin-table', - deleteAction: 'commerce/tax-zones/delete', - tableData: {$tableData}, - }); -JS; - HtmlStack::js($js, Position::BodyEnd); - - return $this->storeManagementCpScreen($storeHandle) - ->additionalButtonsHtml(NewHtml::a(t('New tax zone', category: 'commerce'), $store->getStoreSettingsUrl('taxzones/new'), ['class' => 'btn submit add icon'])) - ->contentHtml(NewHtml::tag('div', '', ['id' => 'tax-vue-admin-table'])); - } - - public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse - { - $store = $this->resolveStore($storeHandle); - $storeHandle = $store->handle; - - if ($id) { - $taxZone = app(TaxZones::class)->getTaxZoneById($id, $store->id); - abort_if($taxZone === null, 404); - } else { - $taxZone = new TaxAddressZone(['storeId' => $store->id]); - } - - $title = $taxZone->id ? $taxZone->name : t('Create a tax zone', category: 'commerce'); - - $condition = $taxZone->getCondition(); - $condition->mainTag = 'div'; - $condition->name = 'condition'; - $condition->id = 'condition'; - - $metaSidebar = ''; - if ($taxZone->id) { - $metaSidebar = Cp::metadataHtml([ - t('Created at') => I18N::getFormatter()->asDatetime($taxZone->dateCreated, 'short'), - t('Updated at') => I18N::getFormatter()->asDatetime($taxZone->dateUpdated, 'short'), - ]); - } - - return $this->storeManagementCpScreen($storeHandle, false) - ->title($title) - ->addCrumb(t('Tax Zones', category: 'commerce'), $store->getStoreSettingsUrl('taxzones')) - ->selectedSubnavItem('store-management') - ->action('commerce/tax-zones/save') - ->redirectUrl($store->getStoreSettingsUrl('taxzones')) - ->metaSidebarHtml($metaSidebar) - ->contentTemplate('commerce/store-management/tax/taxzones/_edit', [ - 'taxZone' => $taxZone, - 'store' => $store, - 'conditionHtml' => new ConditionBuilderRenderer($condition)->render(), - ]); - } - - public function save(Request $request): Response - { - $taxZone = new TaxAddressZone(); - - $taxZone->id = $request->input('taxZoneId') ? (int)$request->input('taxZoneId') : null; - $taxZone->storeId = $request->input('storeId') ? (int)$request->input('storeId') : null; - $this->requireStoreAccess($taxZone->storeId); - $taxZone->name = $request->input('name'); - $taxZone->description = $request->input('description'); - $taxZone->default = (bool)$request->input('default'); - $taxZone->setCondition($request->input('condition')); - - if ($taxZone->validate() && app(TaxZones::class)->saveTaxZone($taxZone)) { - return $this->asModelSuccess( - $taxZone, - t('Tax zone saved.', category: 'commerce'), - 'taxZone', - data: [ - 'id' => $taxZone->id, - 'name' => $taxZone->name, - ] - ); - } - - return $this->asModelFailure( - $taxZone, - t('Couldn\'t save tax zone.', category: 'commerce'), - 'taxZone' - ); - } - - public function delete(Request $request): Response - { - abort_unless($request->expectsJson(), 400); - - $id = $request->input('id'); - abort_if(!$id, 400, 'Missing tax zone id'); - - $taxZone = app(TaxZones::class)->getTaxZoneById((int)$id); - if ($taxZone) { - $this->requireStoreAccess($taxZone->storeId); - } - - app(TaxZones::class)->deleteTaxZoneById((int)$id); - return $this->asSuccess(); - } - - public function testZip(Request $request): Response - { - abort_unless($request->expectsJson(), 400); - - $zipCodeFormula = (string)$request->input('zipCodeConditionFormula'); - $testZipCode = (string)$request->input('testZipCode'); - - $params = ['zipCode' => $testZipCode]; - if (!app(Formulas::class)->evaluateCondition($zipCodeFormula, $params)) { - return $this->asFailure('failed'); - } - - return $this->asSuccess(); - } -} 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..6dafb44ee3 --- /dev/null +++ b/src/Http/Controllers/StoreManagement/BaseStoreManagementController.php @@ -0,0 +1,243 @@ + /
[ / ...$trail]". + * + * Pass one entry per crumb beyond this controller's own section (in practice almost + * always either none, for an index, or one, for a record being edited). Whichever crumb + * ends up last never links, since it's the page already showing. + * + * @param array{label: string, url?: ?string} ...$trail + * @return list> + */ + final protected function crumbs(Store $store, array ...$trail): array + { + $crumbs = [ + ['label' => t('Commerce', category: 'commerce'), 'href' => cp_url('commerce')], + ...($this->showsStoreSwitcher() ? [$this->storeCrumb($store)] : []), + $this->getSectionCrumb($store), + ...array_map(fn(array $crumb) => ['label' => $crumb['label'], 'href' => $crumb['url'] ?? null], $trail), + ]; + + $crumbs[array_key_last($crumbs)]['href'] = null; + + return $crumbs; + } + + /** + * The store-switcher crumb — a store icon and name that opens a menu of every other store + * the current user can access. It never links anywhere itself; switching stores happens + * entirely through its menu. + * + * @return array + */ + private function storeCrumb(Store $store): array + { + $switchableStores = app(Stores::class)->getAllStores()->filter(function(Store $s) { + foreach ($s->getSites() as $site) { + if (currentUser()?->can('editSite:' . $site->uid)) { + return true; + } + } + + return false; + }); + + return [ + 'label' => t($store->getName(), category: 'site'), + 'icon' => 'store', + 'href' => null, + 'actions' => $switchableStores->map(fn(Store $s) => [ + 'type' => 'link', + 'label' => t($s->getName(), category: 'site'), + 'href' => $s->getStoreSettingsUrl(), + ])->all(), + ]; + } + + /** @return NavItem[] */ + protected function subnav(Store $store): array + { + $currentPath = request()->craftPath(); + $selected = fn(?string $path = null) => $currentPath === "commerce/store-management/{$store->handle}" . ($path ? "/{$path}" : ''); + + $items = []; + + if (currentUser()?->can('commerce-manageGeneralStoreSettings')) { + $items[] = new NavItem() + ->label(t('General', category: 'commerce')) + ->url($store->getStoreSettingsUrl()) + ->selected($selected()); + } + + if (currentUser()?->can('commerce-managePaymentCurrencies')) { + $items[] = new NavItem() + ->label(t('Payment Currencies', category: 'commerce')) + ->url($store->getStoreSettingsUrl('payment-currencies')) + ->selected($selected('payment-currencies')); + } + + if (currentUser()?->can('commerce-managePromotions')) { + $pricingItems = [ + new NavItem() + ->label(t('Discounts', category: 'commerce')) + ->url($store->getStoreSettingsUrl('discounts')) + ->selected($selected('discounts')), + ]; + + $pricingItems[] = app(CatalogPricingRules::class)->canUseCatalogPricingRules() + ? new NavItem() + ->label(t('Pricing Rules', category: 'commerce')) + ->url($store->getStoreSettingsUrl('pricing-rules')) + ->selected($selected('pricing-rules')) + : new NavItem() + ->label(t('Sales', category: 'commerce')) + ->url($store->getStoreSettingsUrl('sales')) + ->selected($selected('sales')); + + $items[] = new NavItem()->label(t('Pricing', category: 'commerce'))->group(true)->subnav($pricingItems); + } + + if (currentUser()?->can('commerce-manageShipping')) { + $items[] = new NavItem()->label(t('Shipping', category: 'commerce'))->group(true)->subnav([ + new NavItem() + ->label(t('Shipping Methods', category: 'commerce')) + ->url($store->getStoreSettingsUrl('shippingmethods')) + ->selected($selected('shippingmethods')), + new NavItem() + ->label(t('Shipping Zones', category: 'commerce')) + ->url($store->getStoreSettingsUrl('shippingzones')) + ->selected($selected('shippingzones')), + new NavItem() + ->label(t('Shipping Categories', category: 'commerce')) + ->url($store->getStoreSettingsUrl('shippingcategories')) + ->selected($selected('shippingcategories')), + ]); + } + + if (currentUser()?->can('commerce-manageTaxes')) { + $taxItems = []; + + if (app(Taxes::class)->viewTaxRates()) { + $taxItems[] = new NavItem() + ->label(t('Tax Rates', category: 'commerce')) + ->url($store->getStoreSettingsUrl('taxrates')) + ->selected($selected('taxrates')); + } + + if (app(Taxes::class)->viewTaxZones()) { + $taxItems[] = new NavItem() + ->label(t('Tax Zones', category: 'commerce')) + ->url($store->getStoreSettingsUrl('taxzones')) + ->selected($selected('taxzones')); + } + + if (app(Taxes::class)->viewTaxCategories()) { + $taxItems[] = new NavItem() + ->label(t('Tax Categories', category: 'commerce')) + ->url($store->getStoreSettingsUrl('taxcategories')) + ->selected($selected('taxcategories')); + } + + if ($taxItems !== []) { + $items[] = new NavItem()->label(t('Tax', category: 'commerce'))->group(true)->subnav($taxItems); + } + } + + return $items; + } + + protected function cpScreenResponse(Store $store): CpScreenResponse + { + return new CpScreenResponse() + ->subnav($this->subnav($store)); + } + + /** + * The color values shared by any component keyed to {@see Color} as its category color + * (tax and shipping categories today), for a {@see \CraftCms\Cms\Form\Controls\ColorSelect} + * control's `->colors()` — narrows its default (the shared UI palette, which includes a + * couple of colors outside this enum) down to exactly what {@see Color::tryFrom()} accepts. + * + * @return list + */ + protected function colorPalette(): array + { + return array_map(fn(Color $color) => $color->value, Color::cases()); + } +} diff --git a/src/Http/Controllers/Settings/CatalogPricingController.php b/src/Http/Controllers/StoreManagement/CatalogPricingController.php similarity index 97% rename from src/Http/Controllers/Settings/CatalogPricingController.php rename to src/Http/Controllers/StoreManagement/CatalogPricingController.php index b38610833c..0dc8bd7b37 100644 --- a/src/Http/Controllers/Settings/CatalogPricingController.php +++ b/src/Http/Controllers/StoreManagement/CatalogPricingController.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace CraftCms\Commerce\Http\Controllers\Settings; +namespace CraftCms\Commerce\Http\Controllers\StoreManagement; use craft\commerce\web\assets\catalogpricing\CatalogPricingAsset; use craft\helpers\Cp; @@ -79,6 +79,7 @@ public function filter(Request $request): JsonResponse $condition = $request->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 98% rename from src/Http/Controllers/Settings/CatalogPricingRulesController.php rename to src/Http/Controllers/StoreManagement/CatalogPricingRulesController.php index f3c9b54cf5..cdd896fa3d 100644 --- a/src/Http/Controllers/Settings/CatalogPricingRulesController.php +++ b/src/Http/Controllers/StoreManagement/CatalogPricingRulesController.php @@ -2,12 +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\Condition\ConditionBuilderRenderer; -use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Http\Responses\CpScreenResponse; use CraftCms\Cms\Support\DateTimeHelper; use CraftCms\Cms\Support\Facades\Conditions; @@ -27,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; @@ -39,11 +37,8 @@ use function CraftCms\Cms\currentUserElement; use function CraftCms\Cms\t; -readonly class CatalogPricingRulesController +readonly class CatalogPricingRulesController extends LegacyStoreManagementController { - use HasStoreManagementScreen; - use RespondsWithFlash; - public function index(?string $storeHandle = null): CpScreenResponse { $store = $this->resolveStore($storeHandle); @@ -380,6 +375,7 @@ 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(); 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 f91d1c2d36..cb4ad98429 100644 --- a/src/Http/Controllers/Settings/DiscountsController.php +++ b/src/Http/Controllers/StoreManagement/DiscountsController.php @@ -2,7 +2,7 @@ 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; @@ -11,7 +11,6 @@ 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; @@ -27,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; @@ -47,11 +45,8 @@ use function CraftCms\Cms\currentUserElement; use function CraftCms\Cms\t; -readonly class DiscountsController +readonly class DiscountsController extends LegacyStoreManagementController { - 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'; @@ -623,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/StoreManagement/LegacyStoreManagementController.php b/src/Http/Controllers/StoreManagement/LegacyStoreManagementController.php new file mode 100644 index 0000000000..d0d5cfbca9 --- /dev/null +++ b/src/Http/Controllers/StoreManagement/LegacyStoreManagementController.php @@ -0,0 +1,28 @@ + t('Payment Currencies', category: 'commerce'), 'href' => $store->getStoreSettingsUrl('payment-currencies')]; + } + + public function index(?string $storeHandle = null): CpScreenResponse + { + $store = $this->resolveStore($storeHandle); + + $rows = app(PaymentCurrencies::class)->getAllPaymentCurrencies($store->id) + ->map(fn(PaymentCurrency $currency) => [ + 'id' => $currency->id, + // getName() is just the ISO code today (there's no display-name lookup yet) — + // matches the "Code" column below exactly, which is a pre-existing redundancy, + // not something introduced here. + 'name' => $currency->primary + ? ['html' => Html::encode(t('{name} (Primary)', ['name' => t($currency->getName(), category: 'site')], category: 'commerce'))] + : ['html' => Html::a(Html::encode(t($currency->getName(), category: 'site')), $currency->getCpEditUrl(), ['class' => 'cell-bold'])], + 'handle' => ['html' => FormFields::copytextHtml(['value' => $currency->iso, 'monospace' => true])], + 'rate' => $currency->primary + ? ['html' => Html::tag('span', Html::encode(t('Base', category: 'commerce')), ['class' => 'token'])] + : (string) $currency->rate, + '_deletable' => !$currency->primary, + ]) + ->values() + ->all(); + + $nodes = [ + Table::make('payment-currencies') + ->columns([ + ['key' => 'name', 'label' => t('Currency', category: 'commerce')], + ['key' => 'handle', 'label' => t('Code', category: 'commerce')], + ['key' => 'rate', 'label' => t('Conversion Rate', category: 'commerce')], + ]) + ->rows($rows) + ->emptyMessage(t('No additional payment currencies exist yet.', category: 'commerce')) + ->createAction(t('New currency', category: 'commerce'), $store->getStoreSettingsUrl('payment-currencies/new')) + ->deletable( + action([self::class, 'delete']), + t('Warning, deleting this currency will stop all payments and refunds in this currency, are you sure you want to delete it?', category: 'commerce'), + ), + ]; + + $title = t('Payment Currencies', category: 'commerce'); + + return $this->cpScreenResponse($store) + ->title($title) + ->crumbs($this->crumbs($store)) + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve(Form::make($nodes), new FormContext()), + ]); + } + + public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse + { + $store = $this->resolveStore($storeHandle); + + if ($id) { + $currency = app(PaymentCurrencies::class)->getPaymentCurrencyById($id, $store->id); + abort_if($currency === null || $currency->storeId !== $store->id, 404); + } else { + $currency = new PaymentCurrency(['storeId' => $store->id]); + } + + // @TODO Use the full currency name instead of the ISO code for the page title + $title = $currency->id ? $currency->iso : t('Create a new currency', category: 'commerce'); + + $currencyOptions = app(Currencies::class)->getAllCurrenciesList(); + $hasCompletedOrders = Order::find()->isCompleted(true)->exists(); + $isoLocked = $currency->id && $currency->primary && $hasCompletedOrders; + + $formatter = app(Formatter::class); + $metaSidebarHtml = $currency->id ? app(ContentHtml::class)->metadataHtml([ + t('Created at') => $formatter->asDateTime($currency->dateCreated, 'short'), + t('Updated at') => $formatter->asDateTime($currency->dateUpdated, 'short'), + ]) : ''; + + $formNodes = [ + HiddenField::make('storeId'), + ]; + + if ($currency->id) { + $formNodes[] = HiddenField::make('currencyId'); + } + + if ($isoLocked) { + // A disabled/readonly Choice never gets a `name` attribute (see + // FormHtmlRenderer::renderControl()), so it wouldn't submit at all — this + // pairs a plain readonly display with a HiddenField carrying the real value + // through, mirroring the old template's readonly-select-plus-hidden-input pair. + $formNodes[] = HiddenField::make('iso'); + $formNodes[] = Field::make(t('Currency Code', category: 'commerce'), Text::make('isoDisplay') + ->mode(ControlMode::ReadOnly) + ->value($currency->iso)) + ->instructions(t('Choose the currency’s ISO code.', category: 'commerce')) + ->warning(t('The primary currency cannot be changed after orders are placed.', category: 'commerce')); + } else { + $formNodes[] = Field::make(t('Currency Code', category: 'commerce'), Choice::make('iso')->options($currencyOptions)) + ->instructions(t('Choose the currency’s ISO code.', category: 'commerce')) + ->required(); + } + + $formNodes[] = Field::make(t('Conversion Rate', category: 'commerce'), Number::make('rate') + ->mode($currency->primary ? ControlMode::ReadOnly : ControlMode::Editable)) + ->instructions(t('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.', [ + 'amount1' => 10, + 'rate' => 1.5, + 'amount2' => 15, + ], category: 'commerce')); + + $values = [ + 'storeId' => $store->id, + 'currencyId' => $currency->id, + 'iso' => $currency->iso, + 'rate' => $currency->rate ?: 1, + ]; + + $form = $this->formResolver->resolve(Form::make($formNodes), new FormContext(values: $values)); + + return $this->cpScreenResponse($store) + ->title($title) + ->crumbs($this->crumbs($store, ...($currency->id ? [['label' => $title]] : []))) + ->action('commerce/payment-currencies/save') + ->redirectUrl($store->getStoreSettingsUrl('payment-currencies')) + ->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'save']), + ], + 'metadataHtml' => $metaSidebarHtml ?: null, + ]); + } + + public function save(Request $request): Response + { + $currency = new PaymentCurrency(); + + $currency->id = $request->input('currencyId') ? (int)$request->input('currencyId') : null; + $currency->storeId = (int)$request->input('storeId'); + $this->requireStoreAccess($currency->storeId); + $currency->iso = $request->input('iso'); + $currency->rate = (float)$request->input('rate', 1); + + if (app(PaymentCurrencies::class)->savePaymentCurrency($currency)) { + return $this->asModelSuccess($currency, t('Currency saved.', category: 'commerce'), 'currency'); + } + + return $this->asModelFailure($currency, t('Couldn’t save currency.', category: 'commerce'), 'currency'); + } + + public function delete(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $id = $request->input('id'); + abort_if(!$id, 400, 'Missing currency id'); + + $currency = app(PaymentCurrencies::class)->getPaymentCurrencyById((int)$id); + if ($currency) { + $this->requireStoreAccess($currency->storeId); + } + + if (!app(PaymentCurrencies::class)->deletePaymentCurrencyById((int)$id)) { + return $this->asFailure(); + } + + return $this->asSuccess(); + } +} 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..43b4cf538c 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 LegacyStoreManagementController { - 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/StoreManagement/ShippingCategoriesController.php b/src/Http/Controllers/StoreManagement/ShippingCategoriesController.php new file mode 100644 index 0000000000..6d738e5bdf --- /dev/null +++ b/src/Http/Controllers/StoreManagement/ShippingCategoriesController.php @@ -0,0 +1,407 @@ + t('Shipping Categories', category: 'commerce'), 'href' => $store->getStoreSettingsUrl('shippingcategories')]; + } + + public function index(?string $storeHandle = null): CpScreenResponse + { + $store = $this->resolveStore($storeHandle); + + $shippingCategories = app(ShippingCategories::class)->getAllShippingCategories($store->id); + + $rows = $shippingCategories + ->map(fn(ShippingCategory $shippingCategory) => [ + 'id' => $shippingCategory->id, + 'name' => ['html' => Cp::chipHtml($shippingCategory, [ + 'labelHtml' => Html::a(Html::encode(t($shippingCategory->name, category: 'site')), $shippingCategory->getCpEditUrl(), ['class' => 'cell-bold']), + ])], + 'handle' => $shippingCategory->handle, + 'description' => t($shippingCategory->description, category: 'site'), + 'default' => $shippingCategory->default ? ['icon' => 'check', 'label' => t('Yes')] : '', + '_deletable' => $shippingCategories->count() > 1 && !$shippingCategory->default, + ]) + ->values() + ->all(); + + $nodes = [ + Table::make('shipping-categories') + ->columns([ + ['key' => 'name', 'label' => t('Name')], + ['key' => 'handle', 'label' => t('Handle')], + ['key' => 'description', 'label' => t('Description', category: 'commerce')], + ['key' => 'default', 'label' => t('Default Category', category: 'commerce')], + ]) + ->rows($rows) + ->emptyMessage(t('No shipping categories exist yet.', category: 'commerce')) + ->createAction(t('New shipping category', category: 'commerce'), $store->getStoreSettingsUrl('shippingcategories/new')) + ->deletable(action([self::class, 'delete']), bulk: true) + ->bulkActions([ + [ + 'icon' => 'settings', + 'items' => [ + [ + 'label' => t('Set default category', category: 'commerce'), + 'url' => action([self::class, 'setDefaultCategory']), + 'params' => ['storeHandle' => $store->handle], + 'allowMultiple' => false, + ], + ], + ], + ]), + ]; + + $title = t('Shipping Categories', category: 'commerce'); + + return $this->cpScreenResponse($store) + ->title($title) + ->crumbs($this->crumbs($store)) + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve(Form::make($nodes), new FormContext()), + ]); + } + + public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse + { + $store = $this->resolveStore($storeHandle); + + if ($id) { + $shippingCategory = app(ShippingCategories::class)->getShippingCategoryById($id, $store->id); + abort_if($shippingCategory === null, 404); + } else { + $shippingCategory = new ShippingCategory(['storeId' => $store->id]); + } + + $title = $shippingCategory->id ? $shippingCategory->name : t('Create a new shipping category', category: 'commerce'); + $lockDefault = $this->lockDefault($shippingCategory, $store); + + $formatter = app(Formatter::class); + $metadataHtml = $shippingCategory->id ? app(ContentHtml::class)->metadataHtml([ + t('Created at') => $formatter->asDateTime($shippingCategory->dateCreated, 'short'), + t('Updated at') => $formatter->asDateTime($shippingCategory->dateUpdated, 'short'), + ]) : null; + + $values = $this->initialValues($shippingCategory, $store); + + $form = $this->formResolver->resolve( + $this->buildForm($shippingCategory, $values, $lockDefault), + new FormContext(values: $values, refreshable: true), + ); + + return $this->cpScreenResponse($store) + ->title($title) + ->crumbs($this->crumbs($store, ...($shippingCategory->id ? [['label' => $title]] : []))) + ->action('commerce/shipping-categories/save') + ->redirectUrl($store->getStoreSettingsUrl('shippingcategories')) + ->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'save']), + ], + 'refreshUrl' => action([self::class, 'renderForm']), + 'metadataHtml' => $metadataHtml, + ]); + } + + /** + * Re-resolves the {@see edit()} Form tree for the values currently in progress on the + * client, so toggling "Default Category" can force every product type on (and disable + * further picking) without a full page reload. + */ + public function renderForm(Request $request): JsonResponse + { + $request->validate([ + 'values' => ['required', 'array'], + 'values.storeId' => ['required', 'integer'], + 'values.shippingCategoryId' => ['nullable', 'integer'], + 'scope' => ['present', 'array', 'size:0'], + ]); + + $values = $request->input('values'); + $store = app(Stores::class)->getStoreById((int)$values['storeId']); + abort_if($store === null, 404); + + $shippingCategoryId = $values['shippingCategoryId'] ?? null; + if ($shippingCategoryId) { + $shippingCategory = app(ShippingCategories::class)->getShippingCategoryById((int)$shippingCategoryId, $store->id); + abort_if($shippingCategory === null, 404); + } else { + $shippingCategory = new ShippingCategory(['storeId' => $store->id]); + } + + $lockDefault = $this->lockDefault($shippingCategory, $store); + + // The client only posts values for controls currently in the rendered tree, so layer + // them over this model's real defaults before re-resolving — otherwise a field that's + // about to be revealed (or the productTypes list, once `default` forces it) would fall + // back to empty instead of its actual value. + $values = array_replace($this->initialValues($shippingCategory, $store), $values); + + // `default` just flipped on (that's what triggered this round trip): the productTypes + // control is about to render disabled, so its stale posted selection — captured before + // the toggle — needs overriding to "every product type" rather than merged in as-is. + if ($values['default'] ?? false) { + $values['productTypes'] = array_column(app(ProductTypes::class)->getAllProductTypes(), 'id'); + } + + $form = $this->formResolver->resolve( + $this->buildForm($shippingCategory, $values, $lockDefault), + new FormContext(values: $values, refreshable: true), + ); + + return new JsonResponse(['form' => $form]); + } + + private function lockDefault(ShippingCategory $shippingCategory, Store $store): bool + { + if (!$shippingCategory->id) { + return false; + } + + $allShippingCategories = app(ShippingCategories::class)->getAllShippingCategories($store->id); + $isOnlyCategory = $allShippingCategories->count() === 1 && $allShippingCategories->firstWhere('id', $shippingCategory->id); + + return $isOnlyCategory || $shippingCategory->default; + } + + /** @return array */ + private function initialValues(ShippingCategory $shippingCategory, Store $store): array + { + // A default category is available to every product type, whether or not it's ever + // been explicitly assigned to them — mirrors save()'s own handling below. + $productTypes = $shippingCategory->default + ? array_column(app(ProductTypes::class)->getAllProductTypes(), 'id') + : $shippingCategory->getProductTypeIds(); + + return [ + 'storeId' => $store->id, + 'shippingCategoryId' => $shippingCategory->id, + 'name' => $shippingCategory->name, + 'handle' => $shippingCategory->handle, + 'icon' => $shippingCategory->icon, + 'color' => $shippingCategory->color ?? '', + 'description' => $shippingCategory->description, + 'productTypes' => $productTypes, + 'default' => $shippingCategory->default, + 'defaultDisplay' => $shippingCategory->default, + ]; + } + + /** @param array $values */ + private function buildForm(ShippingCategory $shippingCategory, array $values, bool $lockDefault): Form + { + $isDefault = (bool)($values['default'] ?? false); + + $handle = Handle::make('handle'); + if (!$shippingCategory->id) { + $handle->source('name'); + } + + $productTypesOptions = array_values(array_map( + fn($productType) => ['label' => $productType->name, 'value' => $productType->id], + app(ProductTypes::class)->getAllProductTypes(), + )); + + $formNodes = [ + HiddenField::make('storeId'), + ]; + + if ($shippingCategory->id) { + $formNodes[] = HiddenField::make('shippingCategoryId'); + } + + $formNodes[] = Field::make(t('Name', category: 'commerce'), Text::make('name')->autofocus()) + ->instructions(t('What this shipping category will be called in the control panel.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Handle', category: 'commerce'), $handle) + ->instructions(t('How you’ll refer to this shipping category in the templates.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Description', category: 'commerce'), Text::make('description')); + $formNodes[] = Field::make(t('Icon', category: 'app'), IconPicker::make('icon')); + $formNodes[] = Field::make(t('Color', category: 'commerce'), ColorSelect::make('color') + ->colors($this->colorPalette()) + ->allowTransparent() + ->blankLabel(t('No color', category: 'app'))); + + $productTypesControl = Choice::make('productTypes')->multiple()->options($productTypesOptions); + if ($isDefault) { + $productTypesControl->mode(ControlMode::Disabled); + } + + $productTypesField = Field::make(t('Available to Product Types', category: 'commerce'), $productTypesControl) + ->instructions($isDefault + ? t('The default shipping category is automatically available to all product types.', category: 'commerce') + : t('Which product types should this category be available to?', category: 'commerce')); + + if ($productTypesOptions === []) { + $productTypesField->warning( + t('There aren’t any product types to select yet.', category: 'commerce') . ' ' . + Html::a(t('Create a product type', category: 'commerce'), 'commerce/settings/producttypes/new', ['class' => 'go']), + ); + } + + $formNodes[] = $productTypesField; + + $defaultKey = $lockDefault ? 'defaultDisplay' : 'default'; + $defaultControl = Lightswitch::make($defaultKey)->mode($lockDefault ? ControlMode::Disabled : ControlMode::Editable); + if (!$lockDefault) { + $defaultControl->reactive(); + } + + $formNodes[] = Field::make(t('Default Category', category: 'commerce'), $defaultControl) + ->instructions(t('This category will be used as the default for all purchasables in this store.', category: 'commerce')); + + if ($lockDefault) { + $formNodes[] = HiddenField::make('default'); + } + + return Form::make($formNodes); + } + + public function save(Request $request): Response + { + $shippingCategory = new ShippingCategory(); + + $shippingCategoryId = $request->input('shippingCategoryId'); + $shippingCategory->id = $shippingCategoryId ? (int)$shippingCategoryId : null; + $storeId = $request->input('storeId'); + $shippingCategory->storeId = $storeId ? (int)$storeId : null; + $this->requireStoreAccess($shippingCategory->storeId); + $shippingCategory->name = $request->input('name'); + $shippingCategory->handle = $request->input('handle'); + $shippingCategory->icon = $request->input('icon'); + // '__blank__' is ColorSelect's internal sentinel for "no color" selected — it should + // never reach here (the client translates it back to '' before posting), but guard + // against it anyway for a genuinely JS-less submission. + $color = $request->input('color'); + $shippingCategory->color = ($color && $color !== '__blank__') ? $color : null; + $shippingCategory->description = $request->input('description'); + $shippingCategory->default = (bool)$request->input('default'); + + if ($shippingCategory->default) { + $productTypes = app(ProductTypes::class)->getAllProductTypes(); + } else { + $postedProductTypes = $request->input('productTypes', []) ?: []; + $productTypes = []; + foreach ($postedProductTypes as $productTypeId) { + if ($productTypeId && $productType = app(ProductTypes::class)->getProductTypeById((int)$productTypeId)) { + $productTypes[] = $productType; + } + } + } + $shippingCategory->setProductTypes($productTypes); + + if (!app(ShippingCategories::class)->saveShippingCategory($shippingCategory)) { + return $this->asModelFailure( + $shippingCategory, + t('Couldn’t save shipping category.', category: 'commerce'), + 'shippingCategory' + ); + } + + return $this->asModelSuccess( + $shippingCategory, + t('Shipping category saved.', category: 'commerce'), + 'shippingCategory', + data: [ + 'id' => $shippingCategory->id, + 'name' => $shippingCategory->name, + ] + ); + } + + public function delete(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $id = $request->input('id'); + $ids = $request->input('ids'); + abort_if((!$id && empty($ids)) || ($id && !empty($ids)), 400, 'id or ids must be specified.'); + + if ($id) { + $ids = [$id]; + } + + $failedIds = []; + foreach ($ids as $deleteId) { + // Resolve first so we can check the record's own store before deleting it — mirrors + // the store-access guard save()/setDefaultCategory() already carry. + $shippingCategory = app(ShippingCategories::class)->getShippingCategoryById((int)$deleteId); + if ($shippingCategory) { + $this->requireStoreAccess($shippingCategory->storeId); + } + + if (!$shippingCategory || !app(ShippingCategories::class)->deleteShippingCategoryById((int)$deleteId)) { + $failedIds[] = $deleteId; + } + } + + if (!empty($failedIds)) { + return $this->asFailure(t('Could not delete {count, number} shipping {count, plural, one{category} other{categories}}.', [ + 'count' => count($failedIds), + ], category: 'commerce')); + } + + return $this->asSuccess(t('Shipping categories deleted.', category: 'commerce')); + } + + public function setDefaultCategory(Request $request): Response + { + $ids = $request->input('ids'); + $storeHandle = $request->input('storeHandle'); + $store = $storeHandle ? app(Stores::class)->getStoreByHandle($storeHandle) : null; + abort_if(!$storeHandle || $store === null, 400, 'Invalid store.'); + $this->requireStoreAccess($store->id); + + if (!empty($ids)) { + $id = Arr::first($ids); + + $shippingCategory = app(ShippingCategories::class)->getShippingCategoryById((int)$id, $store->id); + if ($shippingCategory) { + $shippingCategory->default = true; + if (app(ShippingCategories::class)->saveShippingCategory($shippingCategory)) { + return $this->asSuccess(t('Shipping category updated.', category: 'commerce')); + } + } + } + + return $this->asFailure(t('Unable to set default shipping category.', category: 'commerce')); + } +} diff --git a/src/Http/Controllers/StoreManagement/ShippingMethodsController.php b/src/Http/Controllers/StoreManagement/ShippingMethodsController.php new file mode 100644 index 0000000000..7c3e87cd0e --- /dev/null +++ b/src/Http/Controllers/StoreManagement/ShippingMethodsController.php @@ -0,0 +1,326 @@ + t('Shipping Methods', category: 'commerce'), 'href' => $store->getStoreSettingsUrl('shippingmethods')]; + } + + public function index(?string $storeHandle = null): CpScreenResponse + { + $store = $this->resolveStore($storeHandle); + + $shippingMethods = app(ShippingMethods::class)->getAllShippingMethods($store->id); + + $rows = $shippingMethods + ->map(fn(ShippingMethod $shippingMethod) => [ + 'id' => $shippingMethod->id, + 'name' => ['html' => Cp::chipHtml($shippingMethod, [ + 'showStatus' => true, + 'showThumb' => true, + 'labelHtml' => Html::a(Html::encode(t($shippingMethod->name, category: 'site')), $shippingMethod->getCpEditUrl(), ['class' => 'cell-bold']), + ])], + 'handle' => $shippingMethod->handle, + 'type' => $shippingMethod->getType(), + ]) + ->values() + ->all(); + + $nodes = [ + Table::make('shipping-methods') + ->columns([ + ['key' => 'name', 'label' => t('Name')], + ['key' => 'handle', 'label' => t('Handle')], + ['key' => 'type', 'label' => t('Type', category: 'commerce')], + ]) + ->rows($rows) + ->emptyMessage(t('No shipping methods exist yet.', category: 'commerce')) + ->createAction(t('New shipping method', category: 'commerce'), $store->getStoreSettingsUrl('shippingmethods/new')) + ->deletable(action([self::class, 'delete']), bulk: true) + ->bulkActions([ + [ + 'label' => t('Set status', category: 'commerce'), + 'items' => [ + ['label' => t('Enabled', category: 'commerce'), 'url' => action([self::class, 'updateStatus']), 'params' => ['status' => 'enabled']], + ['label' => t('Disabled', category: 'commerce'), 'url' => action([self::class, 'updateStatus']), 'params' => ['status' => 'disabled']], + ], + ], + ]), + ]; + + $title = t('Shipping Methods', category: 'commerce'); + + return $this->cpScreenResponse($store) + ->title($title) + ->crumbs($this->crumbs($store)) + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve(Form::make($nodes), new FormContext()), + ]); + } + + public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse + { + $store = $this->resolveStore($storeHandle); + + if ($id) { + $shippingMethod = app(ShippingMethods::class)->getShippingMethodById($id, $store->id); + abort_if($shippingMethod === null, 404); + } else { + $shippingMethod = new ShippingMethod(['storeId' => $store->id]); + } + + $title = $shippingMethod->id ? $shippingMethod->name : t('Create a new shipping method', category: 'commerce'); + + $formatter = app(Formatter::class); + $metadataHtml = $shippingMethod->id ? app(ContentHtml::class)->metadataHtml([ + t('Created at') => $formatter->asDateTime($shippingMethod->dateCreated, 'short'), + t('Updated at') => $formatter->asDateTime($shippingMethod->dateUpdated, 'short'), + ]) : null; + + $handle = Handle::make('handle'); + if (!$shippingMethod->id) { + $handle->source('name'); + } + + $formNodes = [ + HiddenField::make('storeId'), + ]; + + if ($shippingMethod->id) { + $formNodes[] = HiddenField::make('shippingMethodId'); + } + + $formNodes[] = Field::make(t('Name', category: 'commerce'), Text::make('name')->autofocus()) + ->required(); + $formNodes[] = Field::make(t('Handle', category: 'commerce'), $handle) + ->instructions(t('How this shipping method will be referred to in templates and forms.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Icon', category: 'app'), IconPicker::make('icon')); + $formNodes[] = Field::make(t('Color', category: 'commerce'), ColorSelect::make('color') + ->colors($this->colorPalette()) + ->allowTransparent() + ->blankLabel(t('No color', category: 'app'))); + + // Neither condition is project-config-tracked (both hardcode forProjectConfig = false + // in their setters, same as zones), so no ->forProjectConfig() here either. + $formNodes[] = Field::make(t('Match Order', category: 'commerce'), ConditionBuilder::make('orderCondition') + ->conditionClass(ShippingMethodOrderCondition::class) + ->value($shippingMethod->getOrderCondition()->getConfig())) + ->instructions(t('Conditions here are matched against an order before looking through the rules. This is useful if you want to qualify a method’s availability early, or if there are common conditions to all rules for this method.', category: 'commerce')); + + $formNodes[] = Field::make(t('Match Customer', category: 'commerce'), ConditionBuilder::make('customerCondition') + ->conditionClass(ShippingMethodCustomerCondition::class) + ->value($shippingMethod->getCustomerCondition()->getConfig())) + ->instructions(t('Conditions here are matched against the order’s customer before looking through the rules. This is useful if you want qualify a method’s availability early or if there are common conditions to all rules for this method.', category: 'commerce')); + + $formNodes[] = Field::make(t('Enable this shipping method on the front end', category: 'commerce'), Lightswitch::make('enabled')); + + if ($shippingMethod->id) { + $shippingRules = app(ShippingRules::class)->getAllShippingRulesByShippingMethodId($shippingMethod->id); + + $rows = collect($shippingRules) + ->map(fn(ShippingRule $rule) => [ + 'id' => $rule->id, + 'name' => ['label' => t($rule->name, category: 'site'), 'url' => $store->getStoreSettingsUrl("shippingmethods/{$shippingMethod->id}/shippingrules/{$rule->id}")], + 'description' => t($rule->description, category: 'site'), + 'details' => $this->ruleDetailsCell($rule), + 'enabled' => $rule->enabled ? ['icon' => 'check', 'label' => t('Yes')] : '', + ]) + ->values() + ->all(); + + $formNodes[] = Table::make('shipping-rules') + ->columns([ + ['key' => 'name', 'label' => t('Shipping Rule', category: 'commerce')], + ['key' => 'description', 'label' => t('Description', category: 'commerce')], + ['key' => 'details', 'label' => t('Details', category: 'commerce')], + ['key' => 'enabled', 'label' => t('Enabled?', category: 'commerce')], + ]) + ->rows($rows) + ->emptyMessage(t('No shipping rules exist yet.', category: 'commerce')) + ->createAction(t('New shipping rule', category: 'commerce'), $store->getStoreSettingsUrl("shippingmethods/{$shippingMethod->id}/shippingrules/new")) + ->reorderable(action([ShippingRulesController::class, 'reorder'])) + ->deletable(action([ShippingRulesController::class, 'delete'])); + } + + $values = [ + 'storeId' => $store->id, + 'shippingMethodId' => $shippingMethod->id, + 'name' => $shippingMethod->name, + 'handle' => $shippingMethod->handle, + 'icon' => $shippingMethod->icon, + 'color' => $shippingMethod->color ?? '', + 'orderCondition' => $shippingMethod->getOrderCondition()->getConfig(), + 'customerCondition' => $shippingMethod->getCustomerCondition()->getConfig(), + 'enabled' => $shippingMethod->enabled, + ]; + + $form = $this->formResolver->resolve(Form::make($formNodes), new FormContext(values: $values)); + + return $this->cpScreenResponse($store) + ->title($title) + ->crumbs($this->crumbs($store, ...($shippingMethod->id ? [['label' => $title]] : []))) + ->action('commerce/shipping-methods/save') + ->redirectUrl($store->getStoreSettingsUrl('shippingmethods/{id}#rules')) + ->submitButtonLabel($shippingMethod->id ? t('Save and set rules', category: 'commerce') : t('Save')) + ->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'save']), + ], + 'metadataHtml' => $metadataHtml, + ]); + } + + /** @return array|string */ + private function ruleDetailsCell(ShippingRule $rule): array|string + { + $lines = []; + + if ($rule->baseRate > 0) { + $lines[] = t('Base Rate', category: 'commerce') . ': ' . Currency::formatAsCurrency($rule->baseRate); + } + if ($rule->minRate > 0) { + $lines[] = t('Minimum Total Shipping Cost', category: 'commerce') . ': ' . Currency::formatAsCurrency($rule->minRate); + } + if ($rule->maxRate > 0) { + $lines[] = t('Maximum Total Shipping Cost', category: 'commerce') . ': ' . Currency::formatAsCurrency($rule->maxRate); + } + if ($rule->perItemRate > 0) { + $lines[] = t('Default Per Item Rate', category: 'commerce') . ': ' . Currency::formatAsCurrency($rule->perItemRate); + } + if ($rule->weightRate > 0) { + $lines[] = t('Default Weight Rate', category: 'commerce') . ': ' . Currency::formatAsCurrency($rule->weightRate); + } + if ($rule->percentageRate > 0) { + $lines[] = t('Default Percentage Rate', category: 'commerce') . ': ' . rtrim(rtrim((string) $rule->percentageRate, '0'), '.'); + } + + if ($lines === []) { + return ''; + } + + return ['html' => Html::tag('span', '', [ + 'data-icon' => 'info', + 'title' => implode("\n", $lines), + ])]; + } + + public function save(Request $request): Response + { + $shippingMethod = new ShippingMethod(); + + $shippingMethodId = $request->input('shippingMethodId'); + $shippingMethod->id = $shippingMethodId ? (int)$shippingMethodId : null; + $shippingMethod->name = $request->input('name'); + $shippingMethod->handle = $request->input('handle'); + $shippingMethod->icon = $request->input('icon'); + // '__blank__' is ColorSelect's internal sentinel for "no color" selected — it should + // never reach here (the client translates it back to '' before posting), but guard + // against it anyway for a genuinely JS-less submission. + $color = $request->input('color'); + $shippingMethod->color = ($color && $color !== '__blank__') ? $color : null; + $storeId = $request->input('storeId'); + $shippingMethod->storeId = $storeId ? (int)$storeId : null; + $this->requireStoreAccess($shippingMethod->storeId); + $shippingMethod->setOrderCondition($request->input('orderCondition')); + $shippingMethod->setCustomerCondition($request->input('customerCondition')); + $shippingMethod->enabled = (bool)$request->input('enabled'); + + if (!app(ShippingMethods::class)->saveShippingMethod($shippingMethod)) { + return $this->asModelFailure($shippingMethod, t('Couldn’t save shipping method.', category: 'commerce'), 'shippingMethod'); + } + + return $this->asModelSuccess($shippingMethod, t('Shipping method saved.', category: 'commerce'), 'shippingMethod'); + } + + public function delete(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $id = $request->input('id'); + $ids = $request->input('ids'); + abort_if((!$id && empty($ids)) || ($id && !empty($ids)), 400, 'id or ids must be specified.'); + + if ($id) { + $ids = [$id]; + } + + $failedIds = []; + foreach ($ids as $deleteId) { + $shippingMethod = app(ShippingMethods::class)->getShippingMethodById((int)$deleteId); + if ($shippingMethod) { + $this->requireStoreAccess($shippingMethod->storeId); + } + + if (!$shippingMethod || !app(ShippingMethods::class)->deleteShippingMethodById((int)$deleteId)) { + $failedIds[] = $deleteId; + } + } + + if (!empty($failedIds)) { + return $this->asFailure(t('Could not delete {count, number} shipping {count, plural, one{method} other{methods}} and rules.', [ + 'count' => count($failedIds), + ], category: 'commerce')); + } + + return $this->asSuccess(t('Shipping methods and rules deleted.', category: 'commerce')); + } + + public function updateStatus(Request $request): Response + { + $ids = $request->input('ids'); + $status = $request->input('status'); + + abort_if(empty($ids), 400, 'Missing ids'); + + DB::transaction(function() use ($ids, $status) { + $shippingMethods = ShippingMethodRecord::whereIn('id', $ids)->get(); + + foreach ($shippingMethods as $shippingMethod) { + $this->requireStoreAccess($shippingMethod->storeId); + $shippingMethod->enabled = ($status == 'enabled'); + $shippingMethod->save(); + } + }); + + return $this->asSuccess(t('Shipping methods updated.', category: 'commerce')); + } +} diff --git a/src/Http/Controllers/StoreManagement/ShippingRulesController.php b/src/Http/Controllers/StoreManagement/ShippingRulesController.php new file mode 100644 index 0000000000..9aac643c80 --- /dev/null +++ b/src/Http/Controllers/StoreManagement/ShippingRulesController.php @@ -0,0 +1,490 @@ + t('Shipping Methods', category: 'commerce'), 'href' => $store->getStoreSettingsUrl('shippingmethods')]; + } + + public function edit(?string $storeHandle = null, ?int $methodId = null, ?int $ruleId = null): CpScreenResponse + { + $store = $this->resolveStore($storeHandle); + + $shippingMethod = app(ShippingMethods::class)->getShippingMethodById($methodId, $store->id); + abort_if($shippingMethod === null, 404); + + if ($ruleId) { + $shippingRule = app(ShippingRules::class)->getShippingRuleById($ruleId); + abort_if($shippingRule === null, 404); + } else { + $shippingRule = new ShippingRule(); + $shippingRule->methodId = $shippingMethod->id; + $shippingRule->storeId = $shippingMethod->storeId; + } + + $title = $shippingRule->id ? $shippingRule->name : t('Create a new shipping rule', category: 'commerce'); + + $formatter = app(Formatter::class); + $metadataHtml = $shippingRule->id ? app(ContentHtml::class)->metadataHtml([ + t('Created at') => $formatter->asDateTime($shippingRule->dateCreated, 'short'), + t('Updated at') => $formatter->asDateTime($shippingRule->dateUpdated, 'short'), + ]) : null; + + $values = $this->initialValues($shippingMethod, $shippingRule, $store); + + $form = $this->formResolver->resolve( + $this->buildForm($shippingMethod, $shippingRule, $values, $store), + new FormContext(values: $values, refreshable: true), + ); + + $redirectUrl = $store->getStoreSettingsUrl("shippingmethods/{$shippingMethod->id}#rules"); + + $response = $this->cpScreenResponse($store) + ->title($title) + ->crumbs($this->crumbs( + $store, + ['label' => $shippingMethod->getName(), 'href' => $shippingMethod->getCpEditUrl()], + ...($shippingRule->id ? [['label' => $title]] : []), + )) + ->redirectUrl($redirectUrl); + + if ($shippingRule->id) { + $response + ->addAltAction(t('Save as a new rule', category: 'commerce'), [ + 'action' => action([self::class, 'duplicate']), + 'confirm' => t('Are you sure you want to save this as a new shipping rule?', category: 'commerce'), + ]) + ->addAltAction(t('Delete', category: 'commerce'), [ + 'destructive' => true, + 'action' => action([self::class, 'delete']), + 'confirm' => t('Are you sure you want to delete this shipping rule?', category: 'commerce'), + ]); + } + + return $response->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'save']), + ], + 'refreshUrl' => action([self::class, 'renderForm']), + 'metadataHtml' => $metadataHtml, + ]); + } + + /** + * Re-resolves the {@see edit()} Form tree for the values currently in progress on the + * client, so changing a shipping category's condition can hide or reveal its rate-override + * row on the Costs tab without a full page reload. + */ + public function renderForm(Request $request): JsonResponse + { + $request->validate([ + 'values' => ['required', 'array'], + 'values.storeId' => ['required', 'integer'], + 'values.methodId' => ['required', 'integer'], + 'values.id' => ['nullable', 'integer'], + 'scope' => ['present', 'array', 'size:0'], + ]); + + $values = $request->input('values'); + $store = app(Stores::class)->getStoreById((int) $values['storeId']); + abort_if($store === null, 404); + + $shippingMethod = app(ShippingMethods::class)->getShippingMethodById((int) $values['methodId'], $store->id); + abort_if($shippingMethod === null, 404); + + $ruleId = $values['id'] ?? null; + if ($ruleId) { + $shippingRule = app(ShippingRules::class)->getShippingRuleById((int) $ruleId); + abort_if($shippingRule === null, 404); + } else { + $shippingRule = new ShippingRule(); + $shippingRule->methodId = $shippingMethod->id; + $shippingRule->storeId = $shippingMethod->storeId; + } + + $values = array_replace($this->initialValues($shippingMethod, $shippingRule, $store), $values); + + $form = $this->formResolver->resolve( + $this->buildForm($shippingMethod, $shippingRule, $values, $store), + new FormContext(values: $values, refreshable: true), + ); + + return new JsonResponse(['form' => $form]); + } + + /** @return array */ + private function initialValues(ShippingMethod $shippingMethod, ShippingRule $shippingRule, Store $store): array + { + // Every shipping category gets a fixed row in each of the two tables built in + // buildForm(), keyed by its own id — see how save() recombines them, and how + // buildForm() reads shippingCategoryConditions to hide a disallowed category's + // override row. + $shippingCategories = app(ShippingCategories::class)->getAllShippingCategories($store->id); + $existingRuleCategories = collect($shippingRule->getShippingRuleCategories())->keyBy('shippingCategoryId'); + + $shippingCategoryConditionRows = []; + $ruleCategoryRows = []; + foreach ($shippingCategories as $shippingCategory) { + $ruleCategory = $existingRuleCategories->get($shippingCategory->id); + + $shippingCategoryConditionRows[$shippingCategory->id] = [ + 'name' => Html::encode($shippingCategory->name), + 'condition' => $ruleCategory->condition ?? ShippingRuleCategoryRecord::CONDITION_ALLOW, + ]; + + $ruleCategoryRows[$shippingCategory->id] = [ + 'name' => Html::encode($shippingCategory->name), + 'perItemRate' => $ruleCategory?->perItemRate, + 'weightRate' => $ruleCategory?->weightRate, + 'percentageRate' => $ruleCategory?->percentageRate, + ]; + } + + return [ + 'storeId' => $store->id, + 'methodId' => $shippingMethod->id, + 'id' => $shippingRule->id, + 'name' => $shippingRule->name, + 'description' => $shippingRule->description, + 'enabled' => $shippingRule->enabled, + 'orderCondition' => $shippingRule->getOrderCondition()->getConfig(), + 'customerCondition' => $shippingRule->getCustomerCondition()->getConfig(), + 'orderConditionFormula' => $shippingRule->orderConditionFormula, + 'baseRate' => $shippingRule->baseRate, + 'minRate' => $shippingRule->minRate, + 'maxRate' => $shippingRule->maxRate, + 'defaultRates' => [[ + 'name' => '', + 'perItemRate' => $shippingRule->perItemRate, + 'weightRate' => $shippingRule->weightRate, + 'percentageRate' => $shippingRule->percentageRate, + ]], + 'shippingCategoryConditions' => $shippingCategoryConditionRows, + 'ruleCategories' => $ruleCategoryRows, + ]; + } + + /** @param array $values */ + private function buildForm(ShippingMethod $shippingMethod, ShippingRule $shippingRule, array $values, Store $store): Form + { + $currency = $store->getCurrency()?->getCode() ?? 'USD'; + + $categoryConditionOptions = [ + ['label' => t('Allow', category: 'commerce'), 'value' => ShippingRuleCategoryRecord::CONDITION_ALLOW], + ['label' => t('Disallow', category: 'commerce'), 'value' => ShippingRuleCategoryRecord::CONDITION_DISALLOW], + ['label' => t('Require', category: 'commerce'), 'value' => ShippingRuleCategoryRecord::CONDITION_REQUIRE], + ]; + + $hasShippingCategories = !empty($values['ruleCategories']); + + $formNodes = [ + HiddenField::make('storeId'), + HiddenField::make('methodId'), + ]; + + if ($shippingRule->id) { + $formNodes[] = HiddenField::make('id'); + } + + $conditionsTabFields = [ + // Plain text in 5.x — a bare `

`, not a boxed callout (which `Callout::make()` + // would render as, unlike this). + MarkdownContent::make('conditions-intro', t('Filtering conditions which describe to which orders this rule is applicable to. Write 0 to skip a condition.', category: 'commerce')) + ->displayInPane(false), + + // Neither condition is project-config-tracked (both hardcode forProjectConfig + // = false in their setters, same as the shipping method's own conditions), so + // no ->forProjectConfig() here either. + Field::make(t('Match Order', category: 'commerce'), ConditionBuilder::make('orderCondition') + ->conditionClass(ShippingRuleOrderCondition::class) + ->value($shippingRule->getOrderCondition()->getConfig())), + + Group::make('order-condition-formula-advanced', [ + Field::make(t('Order Condition Formula', category: 'commerce'), Textarea::make('orderConditionFormula')->rows(5)->monospace()) + ->instructions(t('Specify a Twig condition that determines whether the shipping rule should apply to a given order. (The order can be referenced via an `order` variable.)', [ + 'url' => 'https://twig.symfony.com/doc/2.x/templates.html#expressions', + ], category: 'commerce')), + ]) + ->label(t('Advanced', category: 'commerce')) + ->collapsible() + ->expanded((bool) ($values['orderConditionFormula'] ?? '')), + + Field::make(t('Match Customer', category: 'commerce'), ConditionBuilder::make('customerCondition') + ->conditionClass(ShippingRuleCustomerCondition::class) + ->value($shippingRule->getCustomerCondition()->getConfig())), + ]; + + $costsTabFields = [ + Field::make(t('Base Rate', category: 'commerce'), MoneyControl::make('baseRate')->currency($currency)) + ->instructions(t('Shipping costs added to the order as a whole before percentage, item, and weight rates are applied. Set to zero to disable this rate. The whole rule, including this base rate, will not match and apply if the cart only contains non-shippable items like digital products.', category: 'commerce')) + ->required(), + Field::make(t('Minimum Total Shipping Cost', category: 'commerce'), MoneyControl::make('minRate')->currency($currency)) + ->instructions(t('The minimum the customer should spend on shipping. Set to zero to disable.', category: 'commerce')) + ->required(), + Field::make(t('Maximum Total Shipping Cost', category: 'commerce'), MoneyControl::make('maxRate')->currency($currency)) + ->instructions(t('The maximum the customer should spend on shipping. Set to zero to disable.', category: 'commerce')) + ->required(), + Heading::make('item-rates-heading', t('Item Rates', category: 'commerce')), + // A single, fixed (non-addable/removable) row — matches 5.x's own layout, where + // these defaults were the first row of the very table the category overrides + // continue in below, rather than three standalone fields. The row's own values + // are read back out of `defaultRates.0` in save() into the plain `perItemRate`/ + // `weightRate`/`percentageRate` properties; the table itself is purely a layout + // device; note this drops the individual fields' own `required` enforcement + // (the editable table has no per-cell concept of it) — matching 5.x's own table + // row, which had no visible asterisks either; `ShippingRule::getRules()` still + // enforces it server-side regardless. + Field::make(control: TableControl::make('defaultRates') + ->columns([ + 'name' => ['type' => 'heading', 'heading' => t('Name')], + 'perItemRate' => ['type' => 'money', 'heading' => t('Per Item Rate', category: 'commerce'), 'currency' => $currency], + 'weightRate' => ['type' => 'money', 'heading' => t('Weight Rate', category: 'commerce'), 'currency' => $currency], + 'percentageRate' => ['type' => 'number', 'heading' => t('Percentage Rate', category: 'commerce')], + ])), + ]; + + // Legacy hides both the conditions table and the overrides table entirely when the + // store has no shipping categories at all — matched here rather than rendering an + // empty table. + if ($hasShippingCategories) { + $conditionsTabFields[] = Heading::make('shipping-category-conditions-heading', t('Shipping Category Conditions', category: 'commerce')); + // Reactive: changing any category's condition here refreshes the whole Form, which + // recomputes $hiddenRuleCategoryIds below and re-hides/reveals the matching row in + // the Costs tab's `ruleCategories` table — the cross-tab equivalent of legacy's own + // jQuery-driven row hide/show, just server-recomputed on each change instead of + // client-scripted. + $conditionsTabFields[] = Field::make(control: TableControl::make('shippingCategoryConditions') + ->keyed() + ->columns([ + 'name' => ['type' => 'heading', 'heading' => t('Name')], + 'condition' => ['type' => 'select', 'heading' => t('Condition', category: 'commerce'), 'options' => $categoryConditionOptions], + ]) + ->reactive()); + + // 5.x rendered the tip below this heading as a `craft-field`-less tip — its own + // ``, the exact + // same markup a Field's own `tip()` produces (confirmed in `field.ts`'s + // `_noticeTemplate()`) but a Field always places its tip *after* the input, not + // before — so building it directly as a standalone Callout node instead + // reproduces both the position and the styling. + $costsTabFields[] = Heading::make('category-rate-overrides-heading', t('Category Rate Overrides', category: 'commerce')); + $costsTabFields[] = Callout::make('category-rate-overrides-tip', t('Leave a category rate override blank to use the rate from above.', category: 'commerce')) + ->variant('info') + ->appearance('plain') + ->padding('none'); + + // A disallowed category's override rate is inert either way (it can never match + // an order), so its row is hidden — matching 5.x's own cross-tab jQuery hide/show. + // Computed fresh from the *current* (possibly just-posted, on a reactive refresh) + // shippingCategoryConditions rather than the row's own stored condition, and passed + // as a Control prop rather than baked into the row's value: props are always + // reapplied fresh on a refresh, whereas row values only ever get merged in where + // missing (so an already-known row's data can't be updated this way). + $hiddenRuleCategoryIds = array_map('strval', array_keys(array_filter( + (array) ($values['shippingCategoryConditions'] ?? []), + fn(array $row): bool => ($row['condition'] ?? ShippingRuleCategoryRecord::CONDITION_ALLOW) === ShippingRuleCategoryRecord::CONDITION_DISALLOW, + ))); + + $costsTabFields[] = Field::make(control: TableControl::make('ruleCategories') + ->keyed() + ->columns([ + 'name' => ['type' => 'heading', 'heading' => t('Name')], + 'perItemRate' => ['type' => 'money', 'heading' => t('Per Item Rate', category: 'commerce'), 'currency' => $currency], + 'weightRate' => ['type' => 'money', 'heading' => t('Weight Rate', category: 'commerce'), 'currency' => $currency], + 'percentageRate' => ['type' => 'number', 'heading' => t('Percentage Rate', category: 'commerce')], + ]) + ->hiddenRows($hiddenRuleCategoryIds)); + } + + return Form::make($formNodes) + ->addTab(t('Rule', category: 'commerce'), [ + Field::make(t('Name', category: 'commerce'), Text::make('name')->autofocus()) + ->instructions(t('What this shipping rule will be called in the control panel.', category: 'commerce')) + ->required(), + Field::make(t('Description', category: 'commerce'), Text::make('description')) + ->instructions(t('Describe this rule.', category: 'commerce')), + Field::make(t('Enable this shipping rule', category: 'commerce'), Lightswitch::make('enabled')), + ]) + ->addTab(t('Conditions', category: 'commerce'), $conditionsTabFields) + ->addTab(t('Costs', category: 'commerce'), $costsTabFields); + } + + public function duplicate(Request $request): Response + { + return $this->save($request, duplicate: true); + } + + public function save(Request $request, bool $duplicate = false): Response + { + $shippingRule = new ShippingRule(); + + if (!$duplicate) { + $shippingRule->id = $request->input('id') ? (int)$request->input('id') : null; + } + $shippingRule->storeId = $request->input('storeId') ? (int)$request->input('storeId') : null; + $this->requireStoreAccess($shippingRule->storeId); + + $moneyInputs = [ + 'baseRate', + 'maxRate', + 'minRate', + ]; + + foreach ($moneyInputs as $moneyInput) { + // The `Money` Form Control's canonical value stays a plain scalar (whatever + // shape the initial `values` array supplied) until it's actually edited, at + // which point it becomes `{value, locale}` — same duality + // `Money::renderHtml()` itself already handles for display. `locale` is only + // ever present once a real edit happened, so it's fine to omit here for an + // untouched field: the value is already the plain decimal `ShippingRule` + // property it started as, not a locale-formatted string needing parsing. + $input = $request->input($moneyInput); + $input = is_array($input) ? $input : ['value' => $input]; + $input += [ + 'currency' => $shippingRule->getStore()->getCurrency(), + ]; + $shippingRule->$moneyInput = (float)Money::toDecimal(Money::toMoney($input)); + } + + $shippingRule->name = $request->input('name'); + $shippingRule->description = $request->input('description'); + $shippingRule->methodId = $request->input('methodId') ? (int)$request->input('methodId') : null; + $shippingRule->enabled = (bool)$request->input('enabled'); + $shippingRule->orderConditionFormula = trim((string)$request->input('orderConditionFormula', '')); + + // `defaultRates` is the single fixed row of the Item Rates table built in edit() — + // a layout device matching 5.x's own combined table, not a real per-category row. Its + // three cells map straight onto these plain properties. + $defaultRates = (array) ($request->input('defaultRates')[0] ?? []); + foreach (['perItemRate', 'weightRate'] as $moneyInput) { + $input = $defaultRates[$moneyInput] ?? null; + $input = is_array($input) ? $input : ['value' => $input]; + $input += [ + 'currency' => $shippingRule->getStore()->getCurrency(), + ]; + $shippingRule->$moneyInput = (float)Money::toDecimal(Money::toMoney($input)); + } + $shippingRule->percentageRate = (float)Localization::normalizeNumber($defaultRates['percentageRate'] ?? 0); + + $shippingRule->setOrderCondition($request->input('orderCondition')); + $shippingRule->setCustomerCondition($request->input('customerCondition')); + + // The Conditions and Costs tabs each own a separate keyed table (see edit()) — + // recombine them here into the single per-category model save() actually needs. + $conditions = (array)$request->input('shippingCategoryConditions'); + $rates = (array)$request->input('ruleCategories'); + + $ruleCategories = []; + foreach (array_unique([...array_keys($conditions), ...array_keys($rates)]) as $key) { + $rateRow = $rates[$key] ?? []; + $perItemRate = $rateRow['perItemRate'] ?? null; + $weightRate = $rateRow['weightRate'] ?? null; + $percentageRate = $rateRow['percentageRate'] ?? null; + + $ruleCategory = [ + 'condition' => $conditions[$key]['condition'] ?? ShippingRuleCategoryRecord::CONDITION_ALLOW, + 'perItemRate' => (!isset($perItemRate) || trim((string)($perItemRate['value'] ?? '')) === '') + ? null + : Money::toDecimal(Money::toMoney(array_merge([ + 'currency' => $shippingRule->getStore()->getCurrency(), + ], $perItemRate))), + 'weightRate' => (!isset($weightRate) || trim((string)($weightRate['value'] ?? '')) === '') + ? null + : Money::toDecimal(Money::toMoney(array_merge([ + 'currency' => $shippingRule->getStore()->getCurrency(), + ], $weightRate))), + 'percentageRate' => (!isset($percentageRate) || trim((string)$percentageRate) === '') ? null : Localization::normalizeNumber($percentageRate), + ]; + + $ruleCategories[$key] = new ShippingRuleCategory($ruleCategory); + $ruleCategories[$key]->shippingCategoryId = (int)$key; + } + + $shippingRule->setShippingRuleCategories($ruleCategories); + + if (!app(ShippingRules::class)->saveShippingRule($shippingRule)) { + return $this->asModelFailure($shippingRule, t('Couldn’t save shipping rule.', category: 'commerce'), 'shippingRule'); + } + + return $this->asModelSuccess($shippingRule, t('Shipping rule saved.', category: 'commerce'), 'shippingRule'); + } + + public function reorder(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + abort_unless($request->input('ids'), 400, 'Missing ids'); + + $ids = Json::decode($request->input('ids')); + app(ShippingRules::class)->reorderShippingRules($ids); + + return $this->asSuccess(); + } + + public function delete(Request $request): Response + { + $id = $request->input('id'); + abort_if(!$id, 400, 'Shipping rule ID not submitted'); + $id = (int)$id; + + $rule = app(ShippingRules::class)->getShippingRuleById($id); + abort_if($rule === null, 400, 'Cannot find shipping rule to delete'); + $this->requireStoreAccess($rule->storeId); + + if (!app(ShippingRules::class)->deleteShippingRuleById($id)) { + return $this->asFailure(t('Could not delete shipping rule', category: 'commerce')); + } + + return $this->asSuccess(t('Shipping rule deleted.', category: 'commerce')); + } + + private function percentSymbol(): string + { + return I18N::getFormattingLocale()->getNumberSymbol(Locale::SYMBOL_PERCENT); + } +} diff --git a/src/Http/Controllers/StoreManagement/ShippingZonesController.php b/src/Http/Controllers/StoreManagement/ShippingZonesController.php new file mode 100644 index 0000000000..1c97e6312f --- /dev/null +++ b/src/Http/Controllers/StoreManagement/ShippingZonesController.php @@ -0,0 +1,196 @@ + t('Shipping Zones', category: 'commerce'), 'href' => $store->getStoreSettingsUrl('shippingzones')]; + } + + public function index(?string $storeHandle = null): CpScreenResponse + { + $store = $this->resolveStore($storeHandle); + + $rows = app(ShippingZones::class)->getAllShippingZones($store->id) + ->map(fn(ShippingAddressZone $shippingZone) => [ + 'id' => $shippingZone->id, + 'name' => ['html' => Html::a(Html::encode(t($shippingZone->name, category: 'site')), $shippingZone->getCpEditUrl(), ['class' => 'cell-bold'])], + 'description' => t($shippingZone->description, category: 'site'), + ]) + ->values() + ->all(); + + $nodes = [ + Table::make('shipping-zones') + ->columns([ + ['key' => 'name', 'label' => t('Name')], + ['key' => 'description', 'label' => t('Description', category: 'commerce')], + ]) + ->rows($rows) + ->emptyMessage(t('No shipping zones exist yet.', category: 'commerce')) + ->createAction(t('New shipping zone', category: 'commerce'), $store->getStoreSettingsUrl('shippingzones/new')) + ->deletable(action([self::class, 'delete'])), + ]; + + $title = t('Shipping Zones', category: 'commerce'); + + return $this->cpScreenResponse($store) + ->title($title) + ->crumbs($this->crumbs($store)) + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve(Form::make($nodes), new FormContext()), + ]); + } + + public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse + { + $store = $this->resolveStore($storeHandle); + + if ($id) { + $shippingZone = app(ShippingZones::class)->getShippingZoneById($id, $store->id); + abort_if($shippingZone === null, 404); + } else { + $shippingZone = new ShippingAddressZone(['storeId' => $store->id]); + } + + $title = $shippingZone->id ? $shippingZone->name : t('Create a shipping zone', category: 'commerce'); + + $formatter = app(Formatter::class); + $metadataHtml = $shippingZone->id ? app(ContentHtml::class)->metadataHtml([ + t('Created at') => $formatter->asDateTime($shippingZone->dateCreated, 'short'), + t('Updated at') => $formatter->asDateTime($shippingZone->dateUpdated, 'short'), + ]) : null; + + $formNodes = [ + HiddenField::make('storeId'), + ]; + + if ($shippingZone->id) { + $formNodes[] = HiddenField::make('shippingZoneId'); + } + + $formNodes[] = Field::make(t('Name', category: 'commerce'), Text::make('name')->autofocus()) + ->instructions(t('What this shipping zone will be called in the control panel.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Description', category: 'commerce'), Text::make('description')) + ->instructions(t('Describe this shipping zone.', category: 'commerce')); + // Zones aren't project-config-tracked (Zone::setCondition() hardcodes forProjectConfig + // to false), so this deliberately doesn't call ->forProjectConfig() either. + $formNodes[] = Field::make(t('Address Condition'), ConditionBuilder::make('condition') + ->conditionClass(ZoneAddressCondition::class) + ->value($shippingZone->getCondition()->getConfig())); + + $values = [ + 'storeId' => $store->id, + 'shippingZoneId' => $shippingZone->id, + 'name' => $shippingZone->name, + 'description' => $shippingZone->description, + ]; + + $form = $this->formResolver->resolve(Form::make($formNodes), new FormContext(values: $values)); + + return $this->cpScreenResponse($store) + ->title($title) + ->crumbs($this->crumbs($store, ...($shippingZone->id ? [['label' => $title]] : []))) + ->action('commerce/shipping-zones/save') + ->redirectUrl($store->getStoreSettingsUrl('shippingzones')) + ->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'save']), + ], + 'metadataHtml' => $metadataHtml, + ]); + } + + public function save(Request $request): Response + { + $shippingZone = new ShippingAddressZone(); + + $shippingZone->id = $request->input('shippingZoneId') ? (int)$request->input('shippingZoneId') : null; + $shippingZone->storeId = $request->input('storeId') ? (int)$request->input('storeId') : null; + $this->requireStoreAccess($shippingZone->storeId); + $shippingZone->name = $request->input('name'); + $shippingZone->description = $request->input('description'); + $shippingZone->setCondition($request->input('condition')); + + if ($shippingZone->validate() && app(ShippingZones::class)->saveShippingZone($shippingZone)) { + return $this->asModelSuccess( + $shippingZone, + t('Shipping zone saved.', category: 'commerce'), + 'shippingZone', + data: [ + 'id' => $shippingZone->id, + 'name' => $shippingZone->name, + ] + ); + } + + return $this->asModelFailure( + $shippingZone, + t('Couldn’t save shipping zone.', category: 'commerce'), + 'shippingZone' + ); + } + + public function delete(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $id = $request->input('id'); + abort_if(!$id, 400, 'Missing shipping zone id'); + + $shippingZone = app(ShippingZones::class)->getShippingZoneById((int)$id); + if ($shippingZone) { + $this->requireStoreAccess($shippingZone->storeId); + } + + if (!app(ShippingZones::class)->deleteShippingZoneById((int)$id)) { + return $this->asFailure(t('Could not delete shipping zone', category: 'commerce')); + } + + return $this->asSuccess(); + } + + public function testZip(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $zipCodeFormula = (string)$request->input('zipCodeConditionFormula'); + $testZipCode = (string)$request->input('testZipCode'); + + $params = ['zipCode' => $testZipCode]; + + if (!app(Formulas::class)->evaluateCondition($zipCodeFormula, $params)) { + return $this->asFailure('failed'); + } + + return $this->asSuccess(); + } +} diff --git a/src/Http/Controllers/StoreManagement/StoreManagementController.php b/src/Http/Controllers/StoreManagement/StoreManagementController.php new file mode 100644 index 0000000000..e854887d38 --- /dev/null +++ b/src/Http/Controllers/StoreManagement/StoreManagementController.php @@ -0,0 +1,345 @@ + t('General', category: 'commerce'), 'href' => $store->getStoreSettingsUrl()]; + } + + public function index(): Response|CpScreenResponse + { + $site = Cp::requestedSite(); + /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ + $store = $site->getStore(); + + if (currentUser()?->can('commerce-manageGeneralStoreSettings')) { + return redirect($store->getStoreSettingsUrl()); + } + + if (currentUser()?->can('commerce-managePaymentCurrencies')) { + return redirect($store->getStoreSettingsUrl('payment-currencies')); + } + + if (currentUser()?->can('commerce-managePromotions')) { + return redirect($store->getStoreSettingsUrl('discounts')); + } + + if (currentUser()?->can('commerce-manageShipping')) { + return redirect($store->getStoreSettingsUrl('shipping')); + } + + if (currentUser()?->can('commerce-manageTaxes')) { + return redirect($store->getStoreSettingsUrl('taxrates')); + } + + return $this->cpScreenResponse($store) + ->title(t('Store Management', category: 'commerce')) + ->crumbs($this->crumbs($store)) + ->contentHtml(Html::tag( + 'p', + t('No access given to any specific store management features.', category: 'commerce') + )); + } + + public function edit(?string $storeHandle = null): Response|CpScreenResponse + { + abort_unless(currentUser()?->can('commerce-manageGeneralStoreSettings'), 403); + + if (!$storeHandle) { + $site = Cp::requestedSite(); + /** @phpstan-ignore-next-line method.notFound (getStore() is added to Site via a Macroable macro registered in Plugin::registerBehaviorMacros(), not visible to static analysis) */ + return redirect($site->getStore()->getStoreSettingsUrl()); + } + + $store = app(Stores::class)->getStoreByHandle($storeHandle); + abort_if($store === null, 404); + + $values = $this->initialValues($store); + + $form = $this->formResolver->resolve( + $this->buildForm($store, $values), + new FormContext(values: $values, refreshable: true), + ); + + return $this->cpScreenResponse($store) + ->title(t('General', category: 'commerce')) + ->crumbs($this->crumbs($store)) + ->action('commerce/store-management/save') + ->redirectUrl($store->getStoreSettingsUrl()) + ->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'save']), + ], + 'refreshUrl' => action([self::class, 'renderForm']), + ]); + } + + /** + * Re-resolves the {@see edit()} Form tree for the values currently in progress on the + * client, so changing the store location's country can reveal the right set of + * country-specific address fields without a full page reload. + */ + public function renderForm(Request $request): JsonResponse + { + $request->validate([ + 'values' => ['required', 'array'], + 'values.id' => ['required', 'integer'], + 'scope' => ['present', 'array', 'size:0'], + ]); + + $values = $request->input('values'); + $store = app(Stores::class)->getStoreById((int) $values['id']); + abort_if($store === null, 404); + + $values = array_replace($this->initialValues($store), $values); + + $form = $this->formResolver->resolve( + $this->buildForm($store, $values), + new FormContext(values: $values, refreshable: true), + ); + + return new JsonResponse(['form' => $form]); + } + + /** @return array */ + private function initialValues(Store $store): array + { + $storeSettings = $store->getSettings(); + $locationAddress = $storeSettings->getLocationAddress(); + + $values = [ + 'id' => $store->id, + 'locationAddressCountryCode' => $locationAddress->countryCode, + 'locationAddress' => $locationAddress->toArray(self::LOCATION_ADDRESS_FIELDS), + 'countries' => $storeSettings->getCountries(), + 'marketAddressCondition' => $storeSettings->getMarketAddressCondition()->getConfig(), + ]; + + if (currentUserElement()?->can('commerce-manageInventoryLocations')) { + $values['inventoryLocations'] = app(InventoryLocations::class)->getInventoryLocations($store->id) + ->map(fn(InventoryLocation $inventoryLocation) => $inventoryLocation->id) + ->all(); + } + + return $values; + } + + /** @param array $values */ + private function buildForm(Store $store, array $values): Form + { + $countryOptions = collect(Addresses::getCountryList()) + ->map(fn(string $label, string $value) => ['label' => $label, 'value' => $value]) + ->values() + ->all(); + + $formNodes = [ + HiddenField::make('id'), + + Heading::make('store-location-heading', t('Store Location', category: 'commerce')), + // Plain text in the legacy Twig too — a bare `

`, not a boxed callout. + MarkdownContent::make('store-location-intro', t('This is the address where your store is located. It may be used by various plugins to determine things like shipping and taxes. It could also be used in PDF receipts.', category: 'commerce')) + ->displayInPane(false), + + // Reactive: the address fields below depend on which fields the selected + // country actually uses (e.g. "State" only appears for some countries) — mirrors + // how every other Address-editing screen in the app pairs a country select with + // the address fields, just built directly here instead of through the Address + // element's own field layout (this is the store's own location, not a + // user-facing Address field). + Field::make(t('Country', category: 'commerce'), Choice::make('locationAddressCountryCode') + ->options($countryOptions) + ->reactive()) + ->required(), + Field::make(control: AddressControl::make('locationAddress') + ->countryCode($values['locationAddressCountryCode'])), + + Heading::make('store-markets-heading', t('Store Markets', category: 'commerce')), + // A plain multi-select Choice would render every option as its own checkbox — fine + // for a handful of options, unusable for a ~250-country list. Combobox gives the + // same searchable, chip-based picker the legacy selectize field did. + Field::make(t('Country List', category: 'commerce'), Combobox::make('countries') + ->multiple() + ->options($countryOptions) + ->requireOptionMatch() + ->showAllOnEmpty()) + ->instructions(t('The countries that orders are allowed to be placed from.', category: 'commerce')), + + // Neither of these two strings has a category — matches the legacy controller, + // which called t() on them without one too (falls back to the 'app' category; + // there's no local lang/en/app.php in either repo to verify against the way + // commerce.php settles everything else). + Field::make(t('Order Address Condition'), ConditionBuilder::make('marketAddressCondition') + ->conditionClass(ZoneAddressCondition::class)) + ->instructions(t('Only allow orders with addresses that match the following rules:')), + ]; + + if (currentUserElement()?->can('commerce-manageInventoryLocations')) { + $canCreate = false; + $limit = Plugin::EDITION_PRO_STORE_LIMIT; + $locationsCount = app(InventoryLocations::class)->getAllInventoryLocations()->count(); + + if ($locationsCount < $limit) { + $canCreate = true; + } + if ($this->plugin->is(Plugin::EDITION_ENTERPRISE, '=')) { + $canCreate = true; + } + + $inventoryLocationOptions = app(InventoryLocations::class)->getAllInventoryLocations() + ->map(fn(InventoryLocation $inventoryLocation) => ['label' => $inventoryLocation->name, 'value' => (string) $inventoryLocation->id]) + ->values() + ->all(); + if ($canCreate) { + // No `category` here, matching InventoryLocationsController::edit()'s own title + // for this same screen — not a commerce.php string. + $inventoryLocationOptions[] = ['label' => t('Create a new inventory location'), 'value' => '__add__']; + } + + // Not ->limit(): that caps how many *unselected* options render in the dropdown at + // once (a UI performance guard), not how many the user is allowed to pick — an + // entirely different, unrelated concept that happened to share a name with the + // edition's own inventory-location-count limit computed above. + $inventoryLocationsControl = ComboboxCreate::make('inventoryLocations') + ->multiple() + ->options($inventoryLocationOptions) + ->requireOptionMatch() + ->showAllOnEmpty(); + + if ($canCreate) { + $inventoryLocationsControl + ->createUrl(action([InventoryLocationsController::class, 'edit'])) + ->resultKey('inventoryLocation'); + } + + $formNodes[] = Heading::make('inventory-locations-heading', t('Inventory Locations', category: 'commerce')); + $formNodes[] = Field::make(t('Inventory Locations', category: 'commerce'), $inventoryLocationsControl) + ->instructions(t('The inventory locations this store uses.', category: 'commerce')); + } + + return Form::make($formNodes); + } + + public function save(Request $request): Response + { + abort_unless(currentUser()?->can('commerce-manageGeneralStoreSettings'), 403); + + $storeId = (int)$request->input('id'); + $this->requireStoreAccess($storeId); + $store = app(Stores::class)->getStoreById($storeId); + $storeSettings = app(StoreSettings::class)->getStoreSettingsById($storeId); + $currentUser = currentUserElement(); + + // The location address always exists (getLocationAddress() auto-creates a blank one) — + // unlike the legacy Twig screen, which only ever let you swap in a *different*, + // already-existing address element (editing this one's own fields happened entirely + // out-of-band, via a double-click-to-open element-editor slideout), the address's own + // fields are now posted right alongside everything else here and need saving as a real + // element in their own right. + $locationAddress = $storeSettings->getLocationAddress(); + $locationAddress->countryCode = $request->input('locationAddressCountryCode') ?: $locationAddress->countryCode; + $addressFields = (array)$request->input('locationAddress'); + foreach (self::LOCATION_ADDRESS_FIELDS as $field) { + if (array_key_exists($field, $addressFields)) { + $locationAddress->$field = $addressFields[$field] ?: null; + } + } + + if (!Elements::saveElement($locationAddress)) { + return $this->asModelFailure( + model: $locationAddress, + message: t('Couldn’t save store location address.', category: 'commerce'), + modelName: 'locationAddress', + ); + } + $storeSettings->setLocationAddress($locationAddress); + + $marketAddressCondition = $request->input('marketAddressCondition') ?? new ZoneAddressCondition(); + $storeSettings->setMarketAddressCondition($marketAddressCondition); + $countries = $request->input('countries') ?: []; + $storeSettings->setCountries($countries); + + if ($currentUser?->can('commerce-manageInventoryLocations')) { + $inventoryLocations = $request->input('inventoryLocations'); + + if (!$inventoryLocations) { + return $this->asFailure(t('Missing a default inventory location.', category: 'commerce')); + } + + if (!app(InventoryLocations::class)->saveStoreInventoryLocations($store, $inventoryLocations)) { + return $this->asFailure(t('Inventory locations not saved.', category: 'commerce')); + } + } + + if (!$storeSettings->validate() || !app(StoreSettings::class)->saveStoreSettings($storeSettings)) { + return $this->asModelFailure( + model: $storeSettings, + message: t('Couldn’t save store.', category: 'commerce'), + modelName: 'storeSettings', + ); + } + + return $this->asModelSuccess( + model: $storeSettings, + message: t('Store saved.', category: 'commerce'), + modelName: 'storeSettings', + ); + } +} diff --git a/src/Http/Controllers/StoreManagement/TaxCategoriesController.php b/src/Http/Controllers/StoreManagement/TaxCategoriesController.php new file mode 100644 index 0000000000..91862c4ef6 --- /dev/null +++ b/src/Http/Controllers/StoreManagement/TaxCategoriesController.php @@ -0,0 +1,326 @@ + t('Tax Categories', category: 'commerce'), 'href' => $store->getStoreSettingsUrl('taxcategories')]; + } + + #[\Override] + protected function showsStoreSwitcher(): bool + { + return false; + } + + public function index(?string $storeHandle = null): CpScreenResponse + { + $store = $this->resolveStore($storeHandle); + + $taxCategories = app(TaxCategories::class)->getAllTaxCategories(); + $canDelete = app(Taxes::class)->deleteTaxCategories(); + + $rows = array_map(function(TaxCategory $taxCategory) use ($store, $taxCategories, $canDelete) { + $label = Html::encode(t($taxCategory->name, category: 'site')); + $taxRates = $taxCategory->getTaxRates($store->id); + + return [ + 'id' => $taxCategory->id, + 'name' => ['html' => Cp::chipHtml($taxCategory, [ + 'labelHtml' => Html::a($label, $taxCategory->getCpEditUrl($store->id), ['class' => 'cell-bold']), + ])], + 'handle' => $taxCategory->handle, + 'description' => t($taxCategory->description, category: 'site'), + 'default' => $taxCategory->default ? ['icon' => 'check', 'label' => t('Yes')] : '', + '_deletable' => $canDelete && $taxRates->isEmpty() && count($taxCategories) > 1 && !$taxCategory->default, + ]; + }, $taxCategories); + + $nodes = [ + Table::make('tax-categories') + ->columns([ + ['key' => 'name', 'label' => t('Name')], + ['key' => 'handle', 'label' => t('Handle')], + ['key' => 'description', 'label' => t('Description', category: 'commerce')], + ['key' => 'default', 'label' => t('Default Category', category: 'commerce')], + ]) + ->rows(array_values($rows)) + ->emptyMessage(t('No tax categories exist yet.', category: 'commerce')) + ->when( + app(Taxes::class)->createTaxCategories(), + fn(Table $table) => $table->createAction(t('New tax category', category: 'commerce'), $store->getStoreSettingsUrl('taxcategories/new')), + ) + ->when($canDelete, fn(Table $table) => $table->deletable(action([self::class, 'delete']), bulk: true)), + ]; + + $title = t('Tax Categories', category: 'commerce'); + $engineButtonsHtml = app(Taxes::class)->taxCategoryActionHtml(); + + return $this->cpScreenResponse($store) + ->title($title) + ->crumbs($this->crumbs($store)) + ->when($engineButtonsHtml !== '', fn(CpScreenResponse $screen) => $screen->additionalButtonsHtml($engineButtonsHtml)) + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve(Form::make($nodes), new FormContext()), + ]); + } + + public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse + { + $store = $this->resolveStore($storeHandle); + + if ($id) { + $taxCategory = app(TaxCategories::class)->getTaxCategoryById($id); + abort_if($taxCategory === null, 404); + } else { + $taxCategory = new TaxCategory(); + } + + $title = $taxCategory->id ? $taxCategory->name : t('Create a new tax category', category: 'commerce'); + + $productTypes = app(ProductTypes::class)->getAllProductTypes(); + $productTypesOptions = array_values(array_map( + fn($productType) => ['label' => $productType->name, 'value' => $productType->id], + $productTypes, + )); + + $allTaxCategoryIds = array_keys(app(TaxCategories::class)->getAllTaxCategories()); + $isDefaultAndOnlyCategory = $id && count($allTaxCategoryIds) === 1 && in_array($id, $allTaxCategoryIds); + + $taxRates = collect(); + if ($taxCategory->id) { + app(Stores::class)->getAllStores()->each(fn(Store $s) => $taxRates->push(...$taxCategory->getTaxRates($s->id)->all())); + } + + $formatter = app(Formatter::class); + $metadataHtml = $taxCategory->id ? app(ContentHtml::class)->metadataHtml([ + t('Created at') => $formatter->asDateTime($taxCategory->dateCreated, 'short'), + t('Updated at') => $formatter->asDateTime($taxCategory->dateUpdated, 'short'), + ]) : null; + + $handle = Handle::make('handle'); + if (!$taxCategory->id) { + $handle->source('name'); + } + + $lockDefault = $isDefaultAndOnlyCategory || ($taxCategory->id && $taxCategory->default); + + $formNodes = [ + HiddenField::make('storeId'), + ]; + + if ($taxCategory->id) { + $formNodes[] = HiddenField::make('taxCategoryId'); + } + + $formNodes[] = Field::make(t('Name', category: 'commerce'), Text::make('name')->autofocus()) + ->instructions(t('What this tax category will be called in the control panel.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Handle', category: 'commerce'), $handle) + ->instructions(t('How you’ll refer to this tax category in the templates.', category: 'commerce')) + ->required(); + $formNodes[] = Field::make(t('Icon', category: 'app'), IconPicker::make('icon')); + $formNodes[] = Field::make(t('Color', category: 'commerce'), ColorSelect::make('color') + ->colors($this->colorPalette()) + ->allowTransparent() + ->blankLabel(t('No color', category: 'app'))); + $formNodes[] = Field::make(t('Description', category: 'commerce'), Text::make('description')); + + $productTypesField = Field::make( + t('Available to Product Types', category: 'commerce'), + Choice::make('productTypes')->multiple()->options($productTypesOptions), + )->instructions(t('Which product types should this category be available to?', category: 'commerce')); + + if ($productTypesOptions === []) { + $productTypesField->warning( + t('There aren’t any product types to select yet.', category: 'commerce') . ' ' . + Html::a(t('Create a product type', category: 'commerce'), 'commerce/settings/producttypes/new', ['class' => 'go']), + ); + } + + $formNodes[] = $productTypesField; + + // A locked default can't be un-set here (it's the only category, or already the + // default), so the interactive control moves to a display-only path and the real + // `default` key posts via a paired HiddenField instead — a Disabled control renders + // `name=null` and submits nothing on its own. + $defaultKey = $lockDefault ? 'defaultDisplay' : 'default'; + $defaultField = Field::make( + t('Default Category', category: 'commerce'), + Lightswitch::make($defaultKey)->mode($lockDefault ? ControlMode::Disabled : ControlMode::Editable), + )->instructions(t('New products default to the first tax category available to them. If none are available, this category will be used.', category: 'commerce')); + + $formNodes[] = $defaultField; + + if ($lockDefault) { + $formNodes[] = HiddenField::make('default'); + } + + if ($taxCategory->id && $taxRates->isNotEmpty()) { + $formNodes[] = Table::make('used-by-tax-rates') + ->columns([ + ['key' => 'name', 'label' => t('Rate', category: 'commerce')], + ['key' => 'store', 'label' => t('Store', category: 'commerce')], + ]) + ->rows($taxRates->map(fn($taxRate) => [ + 'id' => $taxRate->id, + 'name' => ['html' => Html::a(Html::encode($taxRate->name), $taxRate->getCpEditUrl())], + 'store' => t($taxRate->getStore()->name, category: 'site'), + ])->values()->all()); + } + + $values = [ + 'storeId' => $store->id, + 'taxCategoryId' => $taxCategory->id, + 'name' => $taxCategory->name, + 'handle' => $taxCategory->handle, + 'icon' => $taxCategory->icon, + 'color' => $taxCategory->color ?? '', + 'description' => $taxCategory->description, + 'productTypes' => $taxCategory->getProductTypeIds(), + 'default' => $taxCategory->default, + 'defaultDisplay' => $taxCategory->default, + ]; + + $form = $this->formResolver->resolve(Form::make($formNodes), new FormContext(values: $values)); + + return $this->cpScreenResponse($store) + ->title($title) + ->crumbs($this->crumbs($store, ...($taxCategory->id ? [['label' => $title]] : []))) + ->action('commerce/tax-categories/save') + ->redirectUrl($store->getStoreSettingsUrl('taxcategories')) + ->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'save']), + ], + 'metadataHtml' => $metadataHtml, + ]); + } + + public function save(Request $request): Response + { + $taxCategory = new TaxCategory(); + + $taxCategory->id = $request->input('taxCategoryId') ? (int)$request->input('taxCategoryId') : null; + $taxCategory->name = $request->input('name'); + $taxCategory->handle = $request->input('handle'); + $taxCategory->icon = $request->input('icon'); + // '__blank__' is ColorSelect's internal sentinel for "no color" selected — it should + // never reach here (the client translates it back to '' before posting), but guard + // against it anyway for a genuinely JS-less submission. + $color = $request->input('color'); + $taxCategory->color = ($color && $color !== '__blank__') ? $color : null; + $taxCategory->description = $request->input('description'); + $taxCategory->default = (bool)$request->input('default'); + + $postedProductTypes = $request->input('productTypes', []) ?: []; + $productTypes = []; + foreach ($postedProductTypes as $productTypeId) { + if ($productTypeId && $productType = app(ProductTypes::class)->getProductTypeById((int)$productTypeId)) { + $productTypes[] = $productType; + } + } + $taxCategory->setProductTypes($productTypes); + + if (!app(TaxCategories::class)->saveTaxCategory($taxCategory)) { + return $this->asModelFailure( + $taxCategory, + t('Couldn’t save tax category.', category: 'commerce'), + 'taxCategory' + ); + } + + return $this->asModelSuccess( + $taxCategory, + t('Tax category saved.', category: 'commerce'), + 'taxCategory' + ); + } + + public function delete(Request $request): Response + { + abort_unless($request->expectsJson(), 400); + + $id = $request->input('id'); + $ids = $request->input('ids'); + abort_if((!$id && empty($ids)) || ($id && !empty($ids)), 400, 'id or ids must be specified.'); + + if ($id) { + $ids = [$id]; + } + + $failedIds = []; + foreach ($ids as $deleteId) { + if (!app(TaxCategories::class)->deleteTaxCategoryById((int)$deleteId)) { + $failedIds[] = $deleteId; + } + } + + if (!empty($failedIds)) { + return $this->asFailure(t('Could not delete {count, number} tax {count, plural, one{category} other{categories}}.', [ + 'count' => count($failedIds), + ], category: 'commerce')); + } + + return $this->asSuccess(t('Tax categories deleted.', category: 'commerce')); + } + + public function setDefaultCategory(Request $request): Response + { + $ids = $request->input('ids'); + abort_if(empty($ids), 400, 'Missing ids'); + + $id = Arr::first($ids); + + $taxCategory = app(TaxCategories::class)->getTaxCategoryById((int)$id); + if ($taxCategory) { + $taxCategory->default = true; + if (app(TaxCategories::class)->saveTaxCategory($taxCategory)) { + return $this->asSuccess(t('Tax category updated.', category: 'commerce')); + } + } + + return $this->asFailure(t('Unable to set default tax category.', category: 'commerce')); + } +} diff --git a/src/Http/Controllers/StoreManagement/TaxRatesController.php b/src/Http/Controllers/StoreManagement/TaxRatesController.php new file mode 100644 index 0000000000..f66903c24f --- /dev/null +++ b/src/Http/Controllers/StoreManagement/TaxRatesController.php @@ -0,0 +1,389 @@ + t('Tax Rates', category: 'commerce'), 'href' => $store->getStoreSettingsUrl('taxrates')]; + } + + public function index(?string $storeHandle = null): CpScreenResponse + { + $store = $this->resolveStore($storeHandle); + + $taxRates = app(TaxRates::class)->getAllTaxRates($store->id); + + $rows = $taxRates + ->map(fn(TaxRate $taxRate) => [ + 'id' => $taxRate->id, + 'name' => ['html' => Html::a(Html::encode(t($taxRate->name, category: 'site')), $taxRate->getCpEditUrl(), ['class' => 'cell-bold'])], + 'rate' => $taxRate->getRateAsPercent(), + 'included' => $taxRate->include ? ['icon' => 'check', 'label' => t('Yes')] : '', + 'removeIncluded' => $taxRate->removeIncluded ? ['icon' => 'check', 'label' => t('Yes')] : '', + 'zone' => $taxRate->getIsEverywhere() ? t('Everywhere', category: 'commerce') : t($taxRate->getTaxZone()->name, category: 'site'), + 'category' => $taxRate->getTaxCategory() ? ['html' => Cp::chipHtml($taxRate->getTaxCategory())] : '', + 'enabled' => $taxRate->enabled ? ['icon' => 'check', 'label' => t('Yes')] : '', + ]) + ->values() + ->all(); + + $nodes = [ + Table::make('tax-rates') + ->columns([ + ['key' => 'name', 'label' => t('Name')], + ['key' => 'rate', 'label' => t('Rate', category: 'commerce')], + ['key' => 'included', 'label' => t('Include in price?', category: 'commerce')], + ['key' => 'removeIncluded', 'label' => t('Remove from price?', category: 'commerce')], + ['key' => 'zone', 'label' => t('Tax Zone', category: 'commerce')], + ['key' => 'category', 'label' => t('Tax Category', category: 'commerce')], + ['key' => 'enabled', 'label' => t('Enabled?', category: 'commerce')], + ]) + ->rows($rows) + ->emptyMessage(t('No tax rates exist yet.', category: 'commerce')) + ->when( + app(Taxes::class)->createTaxRates(), + fn(Table $table) => $table->createAction(t('New tax rate', category: 'commerce'), $store->getStoreSettingsUrl('taxrates/new')), + ) + ->when($this->canDeleteTaxRates(), fn(Table $table) => $table->deletable(action([self::class, 'delete']))), + ]; + + $title = t('Tax Rates', category: 'commerce'); + $engineButtonsHtml = app(Taxes::class)->taxRateActionHtml(); + + return $this->cpScreenResponse($store) + ->title($title) + ->crumbs($this->crumbs($store)) + ->when($engineButtonsHtml !== '', fn(CpScreenResponse $screen) => $screen->additionalButtonsHtml($engineButtonsHtml)) + ->inertiaPage('Form', [ + 'form' => $this->formResolver->resolve(Form::make($nodes), new FormContext()), + ]); + } + + public function edit(?string $storeHandle = null, ?int $id = null): CpScreenResponse + { + abort_unless(app(Taxes::class)->viewTaxRates(), 403, 'Tax engine does not permit you to perform this action'); + + $store = $this->resolveStore($storeHandle); + + if ($id) { + $taxRate = app(TaxRates::class)->getTaxRateById($id, $store->id); + abort_if($taxRate === null, 404); + } else { + $taxRate = new TaxRate(['storeId' => $store->id]); + } + + $title = $taxRate->id ? $taxRate->name : t('Create a new tax rate', category: 'commerce'); + + $formatter = app(Formatter::class); + $metadataHtml = $taxRate->id ? app(ContentHtml::class)->metadataHtml([ + t('Created at') => $formatter->asDateTime($taxRate->dateCreated, 'short'), + t('Updated at') => $formatter->asDateTime($taxRate->dateUpdated, 'short'), + ]) : null; + + $values = $this->initialValues($taxRate, $store); + + $form = $this->formResolver->resolve( + $this->buildForm($taxRate, $values, $store), + new FormContext(values: $values, refreshable: true), + ); + + return $this->cpScreenResponse($store) + ->title($title) + ->crumbs($this->crumbs($store, ...($taxRate->id ? [['label' => $title]] : []))) + ->action('commerce/tax-rates/save') + ->redirectUrl($store->getStoreSettingsUrl('taxrates')) + ->inertiaPage('Form', [ + 'form' => $form, + 'submit' => [ + 'method' => 'post', + 'url' => action([self::class, 'save']), + ], + 'refreshUrl' => action([self::class, 'renderForm']), + 'metadataHtml' => $metadataHtml, + ]); + } + + /** + * Re-resolves the {@see edit()} Form tree for the values currently in progress on the + * client, so switching the taxable subject, toggling "Included in price?", or checking a + * tax ID validator can reveal or hide the fields that depend on them without a full page + * reload. + */ + public function renderForm(Request $request): JsonResponse + { + $request->validate([ + 'values' => ['required', 'array'], + 'values.storeId' => ['required', 'integer'], + 'values.taxRateId' => ['nullable', 'integer'], + 'scope' => ['present', 'array', 'size:0'], + ]); + + $values = $request->input('values'); + $store = app(Stores::class)->getStoreById((int)$values['storeId']); + abort_if($store === null, 404); + + $taxRateId = $values['taxRateId'] ?? null; + if ($taxRateId) { + $taxRate = app(TaxRates::class)->getTaxRateById((int)$taxRateId, $store->id); + abort_if($taxRate === null, 404); + } else { + $taxRate = new TaxRate(['storeId' => $store->id]); + } + + $values = array_replace($this->initialValues($taxRate, $store), $values); + + $form = $this->formResolver->resolve( + $this->buildForm($taxRate, $values, $store), + new FormContext(values: $values, refreshable: true), + ); + + return new JsonResponse(['form' => $form]); + } + + /** @return array */ + private function initialValues(TaxRate $taxRate, Store $store): array + { + return [ + 'storeId' => $store->id, + 'taxRateId' => $taxRate->id, + 'name' => $taxRate->name, + 'code' => $taxRate->code, + 'taxable' => $taxRate->taxable, + 'taxZoneId' => $taxRate->taxZoneId, + // `taxCategoryId` is required, so a Choice control that never fires a real + // change event (e.g. the sole option in a brand-new tax rate's dropdown, never + // explicitly picked) would otherwise post null — a native `