diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e8c794175..a16e43e3f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: - '5.x' + - '5.4' pull_request: permissions: contents: read diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md new file mode 100644 index 0000000000..e0a24e0a69 --- /dev/null +++ b/CHANGELOG-WIP.md @@ -0,0 +1,17 @@ +# Release Notes for Craft Commerce 5.4 (WIP) + +### Store Management +- It is now possible to set a variant’s status from the Product Edit screen. ([#3953](https://github.com/craftcms/commerce/discussions/3953)) +- Added an Order condition builder to gateways. ([#3913](https://github.com/craftcms/commerce/discussions/3913)) + +### Extensibility +- Added `craft\commerce\base\Gateway::setOrderCondition()`. +- Added `craft\commerce\base\Gateway::getOrderCondition()`. +- Added `craft\commerce\base\Gateway::getConfig()`. +- Added `craft\commerce\base\Gateway::hasOrderCondition()`. + +### System +- Fixed a bug where gateway settings weren’t storing project config values consistently. ([#3941](https://github.com/craftcms/commerce/issues/3941)) + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index fef692a217..d4c05b73b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,10 @@ # Release Notes for Craft Commerce -## Unreleased +## 5.3.9 - 2025-04-15 -- Fixed a bug where the customer selection UI was being hidden on the Edit Order page. ([#3968](https://github.com/craftcms/commerce/issues/3968)) +- Fixed a bug where the customer selection menu on Edit Order pgaes could be hidden behind an address card. ([#3968](https://github.com/craftcms/commerce/issues/3968)) - Fixed a PHP error that could occur when rendering a PDF. ([#3967](https://github.com/craftcms/commerce/issues/3967)) -- Fixed a bug where the account activation email template wasn’t always getting rendered in the correct site. +- Fixed a bug where account activation emails weren’t always getting rendered for the correct site. ## 5.3.8 - 2025-04-02 diff --git a/src/Plugin.php b/src/Plugin.php index 9d0a74003b..1e039c10bb 100755 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -83,6 +83,7 @@ use craft\commerce\services\Products; use craft\commerce\services\ProductTypes; use craft\commerce\services\Purchasables; +use craft\commerce\services\Reports; use craft\commerce\services\Sales; use craft\commerce\services\ShippingCategories; use craft\commerce\services\ShippingMethods; @@ -218,6 +219,7 @@ public static function config(): array 'productTypes' => ['class' => ProductTypes::class], 'products' => ['class' => Products::class], 'purchasables' => ['class' => Purchasables::class], + 'reports' => ['class' => Reports::class], 'sales' => ['class' => Sales::class], 'shippingCategories' => ['class' => ShippingCategories::class], 'shippingMethods' => ['class' => ShippingMethods::class], @@ -391,6 +393,13 @@ public function getCpNavItem(): ?array ]; } + if ($userService->checkPermission('commerce-manageReporting')) { + $ret['subnav']['reporting'] = [ + 'label' => Craft::t('commerce', 'Reporting'), + 'url' => 'commerce/reporting', + ]; + } + if (Craft::$app->getUser()->checkPermission('commerce-manageInventoryStockLevels')) { $ret['subnav']['inventory'] = [ 'label' => Craft::t('commerce', 'Inventory'), @@ -599,6 +608,7 @@ private function _registerPermissions(): void ], ], + 'commerce-manageReporting' => ['label' => Craft::t('commerce', 'Manage reporting')], 'commerce-manageSubscriptions' => ['label' => Craft::t('commerce', 'Manage subscriptions')], 'commerce-manageSubscriptionPlans' => ['label' => Craft::t('commerce', 'Manage subscription plans')], 'commerce-manageInventoryStockLevels' => ['label' => Craft::t('commerce', 'Manage inventory stock levels')], diff --git a/src/base/Gateway.php b/src/base/Gateway.php index e6af0b0c9d..0e6bad63a4 100644 --- a/src/base/Gateway.php +++ b/src/base/Gateway.php @@ -9,9 +9,13 @@ use Craft; use craft\base\SavableComponent; +use craft\commerce\elements\conditions\orders\DiscountOrderCondition; +use craft\commerce\elements\conditions\orders\GatewayOrderCondition; use craft\commerce\elements\Order; use craft\commerce\models\payments\BasePaymentForm; use craft\commerce\models\Transaction; +use craft\elements\conditions\ElementConditionInterface; +use craft\helpers\Json; use craft\helpers\StringHelper; use craft\helpers\UrlHelper; @@ -32,6 +36,12 @@ abstract class Gateway extends SavableComponent implements GatewayInterface { use GatewayTrait; + /** + * @var ElementConditionInterface|null + * @since 5.4.0 + */ + private ?ElementConditionInterface $_orderCondition = null; + /** * Returns the name of this payment method. * @@ -103,7 +113,7 @@ public function defineRules(): array $rules = parent::defineRules(); $rules[] = [['paymentType', 'handle'], 'required']; - $rules[] = [['name', 'handle', 'paymentType', 'isFrontendEnabled', 'sortOrder'], 'safe']; + $rules[] = [['name', 'handle', 'paymentType', 'isFrontendEnabled', 'orderCondition', 'sortOrder'], 'safe']; return $rules; } @@ -124,6 +134,10 @@ public function getPaymentConfirmationFormHtml(array $params): string */ public function availableForUseWithOrder(Order $order): bool { + if ($this->hasOrderCondition() && !$this->getOrderCondition()->matchElement($order)) { + return false; + } + return true; } @@ -135,6 +149,16 @@ public function supportsPartialPayment(): bool return true; } + /** + * Returns true if this gateway has an order condition + * + * @since 5.4.0 + */ + public function hasOrderCondition(): bool + { + return $this->getOrderCondition()->getConditionRules() !== []; + } + /** * Returns payment Form HTML */ @@ -157,4 +181,66 @@ public function transactionSupportsRefund(Transaction $transaction): bool { return true; } + + + /** + * Gets the order condition for this gateway + * + * @since 5.4.0 + */ + public function getOrderCondition(): ElementConditionInterface + { + /** @var DiscountOrderCondition $condition */ + $condition = $this->_orderCondition ?? new GatewayOrderCondition(); + $condition->mainTag = 'div'; + $condition->name = 'orderCondition'; + + return $condition; + } + + /** + * Sets the order condition for this gateway + * + * @since 5.4.0 + */ + public function setOrderCondition(ElementConditionInterface|string|array $condition): void + { + if (empty($condition)) { + $this->_orderCondition = null; + return; + } + + if (is_string($condition)) { + $condition = Json::decodeIfJson($condition); + } + + if (!$condition instanceof GatewayOrderCondition) { + $condition['class'] = GatewayOrderCondition::class; + /** @var GatewayOrderCondition $condition */ + $condition = \Craft::$app->getConditions()->createCondition($condition); + } + $condition->forProjectConfig = true; + + $this->_orderCondition = $condition; + } + + /** + * @return array + * @since 5.4.0 + */ + public function getConfig(): array + { + $configData = [ + 'name' => $this->name, + 'handle' => $this->handle, + 'type' => get_class($this), + 'settings' => $this->getSettings(), + 'sortOrder' => ($this->sortOrder ?? 99), + 'paymentType' => $this->paymentType, + 'isFrontendEnabled' => $this->getIsFrontendEnabled(false), + 'orderCondition' => $this->getOrderCondition()->getConfig(), + ]; + + return $configData; + } } diff --git a/src/base/Report.php b/src/base/Report.php new file mode 100644 index 0000000000..8de59eabdb --- /dev/null +++ b/src/base/Report.php @@ -0,0 +1,221 @@ +_data === null) { + $this->_data = $this->getQuery()->all(); + } + + return $this->_data; + } + + /** + * Returns the CP URL for this report + * + * @return string|null + */ + public function getCpEditUrl(): ?string + { + return $this->getHandle() ? UrlHelper::cpUrl('commerce/reporting/' . $this->getHandle()) : null; + } + + /** + * Returns the icon for this report + * + * @return string|null + */ + public function getIcon(): ?string + { + return null; + } + + /** + * @inheritDoc + */ + public function getQuery(): Query + { + return new Query(); + } + + /** + * @inheritDoc + */ + public function getStartDate(): ?DateTime + { + if ($this->_startDate === null) { + $this->_startDate = (new DateTime())->modify('-30 days'); + } + + return $this->_startDate; + } + + /** + * @inheritDoc + */ + public function getEndDate(): ?DateTime + { + if ($this->_endDate === null) { + $this->_endDate = new DateTime(); + } + + return $this->_endDate; + } + + /** + * @inheritDoc + */ + public function setStartDate(?DateTime $date): void + { + $this->_startDate = $date; + $this->_data = null; // Reset cached data + } + + /** + * @inheritDoc + */ + public function setEndDate(?DateTime $date): void + { + $this->_endDate = $date; + $this->_data = null; // Reset cached data + } + + /** + * @inheritDoc + */ + public function getParams(): array + { + return []; + } + + /** + * @inheritDoc + */ + public function setParams(array $params): void + { + // Initialize with default values + $this->_paramValues = []; + + foreach ($this->getParams() as $param) { + $handle = $param['handle']; + $default = $param['default'] ?? null; + + // Set from request or use default + $this->_paramValues[$handle] = $params[$handle] ?? $default; + } + + // Reset cached data since parameters changed + $this->_data = null; + } + + /** + * @inheritDoc + */ + public function getParamValues(): array + { + // If no params have been set yet, initialize with defaults + if (empty($this->_paramValues)) { + foreach ($this->getParams() as $param) { + $handle = $param['handle']; + $default = $param['default'] ?? null; + $this->_paramValues[$handle] = $default; + } + } + + return $this->_paramValues; + } + + /** + * Returns the headers for CSV export + * + * @return array + */ + public function getCsvHeaders(): array + { + $headers = []; + + foreach ($this->getColumns() as $column) { + $headers[] = $column['label']; + } + + return $headers; + } + + /** + * Returns the data formatted for CSV export + * + * @return array + */ + public function getCsvData(): array + { + $data = $this->getData(); + $columns = $this->getColumns(); + $rows = []; + + foreach ($data as $row) { + $csvRow = []; + + foreach ($columns as $column) { + $csvRow[] = $row[$column['value']] ?? ''; + } + + $rows[] = $csvRow; + } + + return $rows; + } +} diff --git a/src/base/ReportInterface.php b/src/base/ReportInterface.php new file mode 100644 index 0000000000..e540d06ae0 --- /dev/null +++ b/src/base/ReportInterface.php @@ -0,0 +1,78 @@ + + * @since 5.0 + */ +class GenerateOrdersController extends Controller +{ + /** + * @var int The number of orders to generate + */ + public int $count = 10; + + /** + * @var int The number of days back to generate orders for + */ + public int $days = 30; + + /** + * @var bool Whether to include manual payment transactions + */ + public bool $withTransactions = true; + + /** + * @var User[]|null Users for order generation + */ + private ?array $_users = null; + + /** + * @var Generator Faker instance + */ + private Generator $_faker; + + /** + * @var \craft\commerce\models\Store Store to use + */ + private $store; + + /** + * @inheritdoc + */ + public function options($actionID): array + { + $options = parent::options($actionID); + $options[] = 'count'; + $options[] = 'days'; + $options[] = 'withTransactions'; + + return $options; + } + + /** + * @inheritdoc + */ + public function init(): void + { + parent::init(); + + // Check if Faker is available + if (!class_exists(Factory::class)) { + throw new Exception('Faker library is required. Run "composer require fakerphp/faker" to install it.'); + } + + $this->_faker = Factory::create(); + $this->store = Plugin::getInstance()->getStores()->getPrimaryStore(); + } + + /** + * Generate fake orders with optional manual payment transactions. + * + * @return int + */ + public function actionIndex(): int + { + if ($this->count <= 0) { + $this->stderr('Count must be greater than 0.' . PHP_EOL, Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + if ($this->days <= 0) { + $this->stderr('Days must be greater than 0.' . PHP_EOL, Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + try { + $this->stdout('Generating ' . $this->count . ' orders...' . PHP_EOL, Console::FG_GREEN); + $this->stdout('Using date range: ' . $this->days . ' days back from now' . PHP_EOL, Console::FG_GREEN); + + if ($this->withTransactions) { + $this->stdout('Including manual payment transactions' . PHP_EOL, Console::FG_GREEN); + } + + $this->stdout(PHP_EOL); + + // Load available purchasable variants + $variants = $this->_getAvailableVariants(); + + if (empty($variants)) { + $this->stderr('No available variants to add to orders. Please create products with variants first.' . PHP_EOL, Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + $this->stdout('Found ' . count($variants) . ' variants to use in orders.' . PHP_EOL, Console::FG_GREEN); + + // Get payment gateway + $gateway = Plugin::getInstance()->getGateways()->getGatewayByHandle('manual'); + + if (!$gateway && $this->withTransactions) { + $this->stderr('Manual gateway not found but transactions were requested. Please create the manual gateway first.' . PHP_EOL, Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + // Create orders + $success = 0; + $failed = 0; + + for ($i = 0; $i < $this->count; $i++) { + $percentComplete = floor(($i / $this->count) * 100); + $this->stdout("\rGenerating order " . ($i + 1) . " of " . $this->count . " ({$percentComplete}%)", Console::FG_GREEN); + + try { + // Create the order + $order = $this->_createFakeOrder($variants); + + // Add manual transaction if requested + if ($this->withTransactions && $gateway) { + $this->_createManualTransaction($order, $gateway); + } + + $success++; + } catch (Throwable $e) { + $this->stderr(PHP_EOL . 'Error creating order: ' . $e->getMessage() . PHP_EOL, Console::FG_RED); + $failed++; + } + } + + $this->stdout(PHP_EOL . PHP_EOL); + $this->stdout('Successfully generated ' . $success . ' orders.' . PHP_EOL, Console::FG_GREEN); + + if ($failed > 0) { + $this->stderr('Failed to generate ' . $failed . ' orders.' . PHP_EOL, Console::FG_RED); + } + } catch (Throwable $e) { + $this->stderr('Error: ' . $e->getMessage() . PHP_EOL, Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + return ExitCode::OK; + } + + /** + * Get available users for order customer assignment + * + * @return User[] + */ + private function _getUsers(): array + { + if ($this->_users === null) { + $this->_users = User::find() + ->limit(100) + ->all(); + + // Create a default user if needed + if (empty($this->_users)) { + $this->stdout('No users found. Creating a default user for orders...' . PHP_EOL, Console::FG_YELLOW); + + $user = new User(); + $user->username = 'customer' . StringHelper::randomString(8); + $user->email = 'customer' . StringHelper::randomString(8) . '@example.com'; + + if (Craft::$app->getElements()->saveElement($user)) { + $this->_users = [$user]; + } + } + } + + return $this->_users; + } + + /** + * Get available variants for order line items + * + * @return Variant[] + */ + private function _getAvailableVariants(): array + { + return Variant::find() + ->limit(50) + ->all(); + } + + /** + * Create a fake address for an order + * + * @return Address + */ + private function _createFakeAddress(): Address + { + $address = new Address(); + $address->fullName = $this->_faker->name(); + $address->addressLine1 = $this->_faker->streetAddress(); + $address->locality = $this->_faker->city(); + $address->administrativeArea = $this->_faker->state(); + $address->postalCode = $this->_faker->postcode(); + $address->countryCode = 'US'; + $address->title = $this->_faker->title(); + $address->firstName = $this->_faker->firstName(); + $address->lastName = $this->_faker->lastName(); + + return $address; + } + + /** + * Create a fake order with random data + * + * @param Variant[] $variants Available variants to add to the order + * @return Order The created order + * @throws Throwable + */ + private function _createFakeOrder(array $variants): Order + { + // Create order + $order = new Order(); + $order->storeId = $this->store->id; + $order->number = Plugin::getInstance()->getCarts()->generateCartNumber(); + $order->orderLanguage = 'en-US'; + + // Set the customer + $users = $this->_getUsers(); + + if (!empty($users)) { + $customer = $this->_faker->randomElement($users); + $order->setCustomer($customer); + } else { + $order->email = $this->_faker->email(); + } + + // Create and set addresses + $shippingAddress = $this->_createFakeAddress(); + $billingAddress = $this->_createFakeAddress(); + + // We need to save the addresses first + Craft::$app->getElements()->saveElement($shippingAddress); + Craft::$app->getElements()->saveElement($billingAddress); + + $order->setShippingAddress($shippingAddress); + $order->setBillingAddress($billingAddress); + + // Currency settings + $order->currency = $this->store->getCurrency()->getCode(); + $order->paymentCurrency = $order->currency; + + // Set order as complete + $order->isCompleted = true; + + // Set random order date within the specified range + $randomTimestamp = $this->_faker->dateTimeBetween('-' . $this->days . ' days', 'now')->getTimestamp(); + $randomDate = DateTimeHelper::toDateTime($randomTimestamp); + $order->dateOrdered = $randomDate; + + // Save the order to get an ID + if (!Craft::$app->getElements()->saveElement($order)) { + throw new Exception('Could not save order: ' . VarDumper::dumpAsString($order->getErrors())); + } + + // Add random line items (1-5) + $itemCount = $this->_faker->numberBetween(1, 5); + + for ($i = 0; $i < $itemCount; $i++) { + // Pick a random variant + $variant = $this->_faker->randomElement($variants); + + // Create line item + $lineItem = new LineItem(); + $lineItem->purchasableId = $variant->id; + $lineItem->qty = $this->_faker->numberBetween(1, 3); + $lineItem->type = LineItemType::Purchasable; + + // Add line item to order + $order->addLineItem($lineItem); + } + + // Update totals + $order->recalculate(); + + // Save the order with the line items + if (!Craft::$app->getElements()->saveElement($order)) { + throw new Exception('Could not save order with line items: ' . VarDumper::dumpAsString($order->getErrors())); + } + + // Complete the order + $orderStatus = Plugin::getInstance()->getOrderStatuses()->getDefaultOrderStatus($this->store->id); + + if ($orderStatus) { + $order->orderStatusId = $orderStatus->id; + + if (!Craft::$app->getElements()->saveElement($order)) { + throw new Exception('Could not save order with status: ' . VarDumper::dumpAsString($order->getErrors())); + } + } + + return $order; + } + + /** + * Create a manual transaction for an order + * + * @param Order $order The order to create a transaction for + * @param \craft\commerce\base\Gateway $gateway The manual gateway + * @return Transaction The created transaction + * @throws Throwable + */ + private function _createManualTransaction(Order $order, $gateway): Transaction + { + // Create an authorize transaction + $transaction = Plugin::getInstance()->getTransactions()->createTransaction($order); + $transaction->type = TransactionRecord::TYPE_AUTHORIZE; + $transaction->status = TransactionRecord::STATUS_SUCCESS; + $transaction->reference = 'MANUAL-' . StringHelper::randomString(12); + + // Save the transaction + if (!Plugin::getInstance()->getTransactions()->saveTransaction($transaction)) { + throw new Exception('Could not save authorize transaction: ' . VarDumper::dumpAsString($transaction->getErrors())); + } + + // Create a capture transaction + $childTransaction = Plugin::getInstance()->getTransactions()->createTransaction($order, $transaction, TransactionRecord::TYPE_CAPTURE); + $childTransaction->status = TransactionRecord::STATUS_SUCCESS; + $childTransaction->reference = 'MANUAL-CAPT-' . StringHelper::randomString(10); + + // Save the child transaction + if (!Plugin::getInstance()->getTransactions()->saveTransaction($childTransaction)) { + throw new Exception('Could not save capture transaction: ' . VarDumper::dumpAsString($childTransaction->getErrors())); + } + + return $transaction; + } +} diff --git a/src/controllers/GatewaysController.php b/src/controllers/GatewaysController.php index 7eddd0f986..15baa93dd3 100644 --- a/src/controllers/GatewaysController.php +++ b/src/controllers/GatewaysController.php @@ -160,6 +160,12 @@ public function actionSave(): ?Response 'isFrontendEnabled' => $this->request->getParam('isFrontendEnabled'), 'settings' => $this->request->getBodyParam('types.' . $type), ]; + + // Handle order condition if it's in the request + $orderCondition = $this->request->getBodyParam('orderCondition'); + if ($orderCondition !== null) { + $config['orderCondition'] = $orderCondition; + } // For new gateway avoid NULL value. if (!$this->request->getBodyParam('id')) { diff --git a/src/controllers/ReportingController.php b/src/controllers/ReportingController.php new file mode 100644 index 0000000000..4effa4991a --- /dev/null +++ b/src/controllers/ReportingController.php @@ -0,0 +1,216 @@ +requirePermission('commerce-manageReporting'); + + $breadcrumbs = [ + ['label' => Craft::t('commerce', 'Reporting'), 'url' => 'commerce/reporting'], + ]; + + $this->view->registerAssetBundle('craft\\web\\assets\\admintable\\AdminTableAsset'); + + // Set up the AdminTable configuration + $adminTable = new AdminTable(); + $adminTable + ->container('#reports-vue-admin-table') + ->columns([ + AdminTable::createTitleColumn(Craft::t('commerce', 'Title', [ + 'icon' => 'gear', + ])), + AdminTable::createHandleColumn(Craft::t('commerce', 'Handle')), + ]) + ->fullPane(true) + ->emptyMessage(Craft::t('commerce', 'No reports exist yet.')) + ->padded(fn() => true) + ->tableDataEndpoint('commerce/reporting/reports-table') + ->search(true); + + // Pass the table configuration to the template + return $this->asCpScreen() + ->crumbs($breadcrumbs) + ->title(Craft::t('commerce', 'Reports')) + ->selectedSubnavItem('reports') + ->contentTemplate('commerce/reporting/_index', [ + 'adminTable' => $adminTable, + ]); + } + + /** + * Returns the data for the reports admin table + */ + public function actionReportsTable(): Response + { + $this->requirePermission('commerce-manageReporting'); + $this->requireAcceptsJson(); + + /** @var Report[] $reports */ + $reports = Plugin::getInstance()->getReports()->getAllReports(); + + $data = []; + + foreach ($reports as $report) { + $reportData = [ + 'title' => $report->getTitle(), + 'handle' => $report->getHandle(), + 'url' => $report->getCpEditUrl(), + ]; + if ($report->getIcon()) { + $reportData['icon'] = $report->getIcon(); + } + $data[] = $reportData; + } + + return $this->asJson(['data' => $data]); + } + + /** + * Displays a report + */ + public function actionView(?string $reportHandle = null): Response + { + $this->requirePermission('commerce-manageReporting'); + + $report = Plugin::getInstance()->getReports()->getReportByHandle($reportHandle); + + if (!$report) { + throw new NotFoundHttpException('Report not found via route handle'); + } + + // Get the date range filters + $startDate = $this->request->getParam('startDate'); + $endDate = $this->request->getParam('endDate'); + $dateRange = $this->request->getParam('dateRange', 'custom'); + + // Set date ranges on the report if provided + if ($startDate) { + $report->setStartDate(DateTimeHelper::toDateTime($startDate)); + } + + if ($endDate) { + $report->setEndDate(DateTimeHelper::toDateTime($endDate)); + } + + // Get custom parameters from request + $paramValues = []; + foreach ($report->getParams() as $param) { + $handle = $param['handle']; + $paramValues[$handle] = $this->request->getParam($handle); + } + + // Set parameters on report + $report->setParams($paramValues); + + // Get report data + $reportData = $report->getData(); + + return $this->asCpScreen() + ->crumbs([ + ['label' => Craft::t('commerce', 'Reporting'), 'url' => 'commerce/reporting'], + ]) + ->title($report->getTitle()) + ->contentTemplate('commerce/reporting/_view', [ + 'report' => $report, + 'reportData' => $reportData, + 'startDate' => $report->getStartDate(), + 'endDate' => $report->getEndDate(), + 'dateRange' => $dateRange, + 'params' => $report->getParams(), + 'paramValues' => $report->getParamValues(), + ]); + } + + /** + * Downloads a report as CSV + */ + public function actionDownload(string $reportHandle): Response + { + $this->requirePermission('commerce-manageReporting'); + + $report = Plugin::getInstance()->getReports()->getReportByHandle($reportHandle); + + if (!$report) { + throw new NotFoundHttpException('Report not found'); + } + + // Get the date range filters + $startDate = $this->request->getParam('startDate'); + $endDate = $this->request->getParam('endDate'); + $dateRange = $this->request->getParam('dateRange', 'custom'); + + // Set date ranges on the report if provided + if ($startDate) { + $report->setStartDate(DateTimeHelper::toDateTime($startDate)); + } + + if ($endDate) { + $report->setEndDate(DateTimeHelper::toDateTime($endDate)); + } + + // Get custom parameters from request + $paramValues = []; + foreach ($report->getParams() as $param) { + $handle = $param['handle']; + $paramValues[$handle] = $this->request->getParam($handle); + } + + // Set parameters on report + $report->setParams($paramValues); + + $filename = $report->getHandle() . '_' . date('Y-m-d') . '.csv'; + + // Get CSV headers and data + $headers = $report->getCsvHeaders(); + $rows = $report->getCsvData(); + + return $this->response->sendContentAsFile( + $this->generateCsv($headers, $rows), + $filename, + ['mimeType' => 'text/csv'] + ); + } + + /** + * Generates a CSV file from headers and rows + */ + private function generateCsv(array $headers, array $rows): string + { + $csv = fopen('php://temp', 'r+'); + + // Add headers + fputcsv($csv, $headers); + + // Add data rows + foreach ($rows as $row) { + fputcsv($csv, $row); + } + + rewind($csv); + $csvContent = stream_get_contents($csv); + fclose($csv); + + return $csvContent; + } +} diff --git a/src/elements/Product.php b/src/elements/Product.php index a56936543d..1d5dc4355e 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -728,6 +728,7 @@ public static function prepElementQueryForTableAttribute(ElementQueryInterface $ /** * @var NestedElementManager|null + * @see getVariantManager() * @since 5.0.0 */ private ?NestedElementManager $_variantManager = null; diff --git a/src/elements/Variant.php b/src/elements/Variant.php index adb687c699..fd183fb83f 100755 --- a/src/elements/Variant.php +++ b/src/elements/Variant.php @@ -370,6 +370,14 @@ public function canDuplicate(User $user): bool return $this->canSave($user); } + /** + * @inheritdoc + */ + protected static function includeSetStatusAction(): bool + { + return true; + } + /** * @inheritdoc * @throws InvalidConfigException diff --git a/src/elements/conditions/orders/GatewayOrderCondition.php b/src/elements/conditions/orders/GatewayOrderCondition.php new file mode 100644 index 0000000000..59ce77bed3 --- /dev/null +++ b/src/elements/conditions/orders/GatewayOrderCondition.php @@ -0,0 +1,28 @@ + + * @since 5.4.0 + */ +class GatewayOrderCondition extends OrderCondition +{ + public function getBuilderHtml($readOnly = false): string + { + if ($readOnly) { + return Html::disableInputs(fn() => parent::getBuilderHtml()); + } + return parent::getBuilderHtml(); + } +} diff --git a/src/fieldlayoutelements/PurchasablePriceField.php b/src/fieldlayoutelements/PurchasablePriceField.php index 1014a66d65..37be022511 100755 --- a/src/fieldlayoutelements/PurchasablePriceField.php +++ b/src/fieldlayoutelements/PurchasablePriceField.php @@ -52,6 +52,14 @@ class PurchasablePriceField extends BaseNativeField */ public bool $required = true; + /** + * @inheritdoc + */ + protected function defaultLabel(?ElementInterface $element = null, bool $static = false): ?string + { + return Craft::t('commerce', 'Price'); + } + /** * @inheritdoc */ diff --git a/src/helpers/AdminTable.php b/src/helpers/AdminTable.php new file mode 100644 index 0000000000..59f22cac49 --- /dev/null +++ b/src/helpers/AdminTable.php @@ -0,0 +1,1191 @@ + + * @since 5.4 + */ +class AdminTable extends BaseObject +{ + // Properties + // ========================================================================= + + /** + * @var array|Closure The actions for the table + */ + private $actions = []; + + /** + * @var bool|Closure Allow multiple selections + */ + private $allowMultipleSelections = true; + + /** + * @var bool|Closure Allow multiple deletions + */ + private $allowMultipleDeletions = true; + + /** + * @var callable|null Before delete callback + */ + private $beforeDelete = null; + + /** + * @var array|Closure The buttons for the table + */ + private $buttons = []; + + /** + * @var bool|Closure Show checkboxes + */ + private $checkboxes = false; + + /** + * @var callable|null Checkbox status callback + */ + private $checkboxStatus = null; + + /** + * @var array|Closure The columns for the table + */ + private $columns = []; + + /** + * @var string|Closure|null The container selector + */ + private $container = null; + + /** + * @var string|Closure|null The delete action URL + */ + private $deleteAction = null; + + /** + * @var callable|null Delete callback + */ + private $deleteCallback = null; + + /** + * @var string|Closure Delete confirmation message + */ + private $deleteConfirmationMessage = 'Are you sure you want to delete "{name}"?'; + + /** + * @var string|Closure Delete fail message + */ + private $deleteFailMessage = 'Couldn\'t delete "{name}".'; + + /** + * @var string|Closure Delete success message + */ + private $deleteSuccessMessage = '"{name}" deleted.'; + + /** + * @var string|Closure Empty message + */ + private $emptyMessage = 'No data available.'; + + /** + * @var array|Closure Footer actions + */ + private $footerActions = []; + + /** + * @var bool|Closure Full page + */ + private $fullPage = false; + + /** + * @var bool|Closure Full pane + */ + private $fullPane = true; + + /** + * @var array|Closure Item labels + */ + private $itemLabels = ['singular' => 'Item', 'plural' => 'Items']; + + /** + * @var int|Closure|null Minimum items + */ + private $minItems = null; + + /** + * @var string|Closure|null Move to page action URL + */ + private $moveToPageAction = null; + + /** + * @var string|Closure No search results message + */ + private $noSearchResults = 'No results'; + + /** + * @var bool|Closure Padded + */ + private $padded = false; + + /** + * @var string|Closure|null Paginated reorder action URL + */ + private $paginatedReorderAction = null; + + /** + * @var int|Closure|null Results per page + */ + private $perPage = null; + + /** + * @var string|Closure|null Reorder action URL + */ + private $reorderAction = null; + + /** + * @var string|Closure Reorder fail message + */ + private $reorderFailMessage = 'Couldn\'t reorder items'; + + /** + * @var string|Closure Reorder success message + */ + private $reorderSuccessMessage = 'Items reordered'; + + /** + * @var bool|Closure Show search + */ + private $search = false; + + /** + * @var string|Closure Search clear button text + */ + private $searchClear = 'Clear'; + + /** + * @var array|Closure Search params + */ + private $searchParams = []; + + /** + * @var string|Closure Search placeholder + */ + private $searchPlaceholder = 'Search'; + + /** + * @var array|Closure|null Table data + */ + private $tableData = null; + + /** + * @var string|Closure|null Table data endpoint + */ + private $tableDataEndpoint = null; + + /** + * @var array Event callbacks + */ + private array $eventCallbacks = []; + + /** + * @var array|null The state variables passed to closures + */ + private ?array $state = null; + + // Public Methods + // ========================================================================= + + /** + * Set actions for the table + * + * @param array|Closure $actions + * @return self + */ + public function actions($actions): self + { + $this->actions = $actions; + return $this; + } + + /** + * Get actions for the table + * + * @return array + */ + public function getActions(): array + { + return $this->evaluateClosureValue($this->actions); + } + + /** + * Set whether to allow multiple selections + * + * @param bool|Closure $allow + * @return self + */ + public function allowMultipleSelections($allow): self + { + $this->allowMultipleSelections = $allow; + return $this; + } + + /** + * Get whether to allow multiple selections + * + * @return bool + */ + public function getAllowMultipleSelections(): bool + { + return $this->evaluateClosureValue($this->allowMultipleSelections); + } + + /** + * Set whether to allow multiple deletions + * + * @param bool|Closure $allow + * @return self + */ + public function allowMultipleDeletions($allow): self + { + $this->allowMultipleDeletions = $allow; + return $this; + } + + /** + * Get whether to allow multiple deletions + * + * @return bool + */ + public function getAllowMultipleDeletions(): bool + { + return $this->evaluateClosureValue($this->allowMultipleDeletions); + } + + /** + * Set the before delete callback + * + * @param callable $callback + * @return self + */ + public function beforeDelete(callable $callback): self + { + $this->beforeDelete = $callback; + return $this; + } + + /** + * Get the before delete callback + * + * @return callable|null + */ + public function getBeforeDelete(): ?callable + { + return $this->beforeDelete; + } + + /** + * Set buttons for the table + * + * @param array|Closure $buttons + * @return self + */ + public function buttons($buttons): self + { + $this->buttons = $buttons; + return $this; + } + + /** + * Get buttons for the table + * + * @return array + */ + public function getButtons(): array + { + return $this->evaluateClosureValue($this->buttons); + } + + /** + * Set whether to show checkboxes + * + * @param bool|Closure $show + * @return self + */ + public function checkboxes($show): self + { + $this->checkboxes = $show; + return $this; + } + + /** + * Get whether to show checkboxes + * + * @return bool + */ + public function getCheckboxes(): bool + { + return $this->evaluateClosureValue($this->checkboxes); + } + + /** + * Set checkbox status callback + * + * @param callable $callback + * @return self + */ + public function checkboxStatus(callable $callback): self + { + $this->checkboxStatus = $callback; + return $this; + } + + /** + * Get checkbox status callback + * + * @return callable|null + */ + public function getCheckboxStatus(): ?callable + { + return $this->checkboxStatus; + } + + /** + * Set columns for the table + * + * @param array|Closure $columns + * @return self + */ + public function columns($columns): self + { + $this->columns = $columns; + return $this; + } + + /** + * Get columns for the table + * + * @return array + */ + public function getColumns(): array + { + return $this->evaluateClosureValue($this->columns); + } + + /** + * Set the container selector + * + * @param string|Closure $selector + * @return self + */ + public function container($selector): self + { + $this->container = $selector; + return $this; + } + + /** + * Get the container selector + * + * @return string|null + */ + public function getContainer(): ?string + { + return $this->evaluateClosureValue($this->container); + } + + /** + * Set the delete action URL + * + * @param string|Closure $url + * @return self + */ + public function deleteAction($url): self + { + $this->deleteAction = $url; + return $this; + } + + /** + * Get the delete action URL + * + * @return string|null + */ + public function getDeleteAction(): ?string + { + return $this->evaluateClosureValue($this->deleteAction); + } + + /** + * Set the delete callback + * + * @param callable $callback + * @return self + */ + public function deleteCallback(callable $callback): self + { + $this->deleteCallback = $callback; + return $this; + } + + /** + * Get the delete callback + * + * @return callable|null + */ + public function getDeleteCallback(): ?callable + { + return $this->deleteCallback; + } + + /** + * Set the delete confirmation message + * + * @param string|Closure $message + * @return self + */ + public function deleteConfirmationMessage($message): self + { + $this->deleteConfirmationMessage = $message; + return $this; + } + + /** + * Get the delete confirmation message + * + * @return string + */ + public function getDeleteConfirmationMessage(): string + { + return $this->evaluateClosureValue($this->deleteConfirmationMessage); + } + + /** + * Set the delete fail message + * + * @param string|Closure $message + * @return self + */ + public function deleteFailMessage($message): self + { + $this->deleteFailMessage = $message; + return $this; + } + + /** + * Get the delete fail message + * + * @return string + */ + public function getDeleteFailMessage(): string + { + return $this->evaluateClosureValue($this->deleteFailMessage); + } + + /** + * Set the delete success message + * + * @param string|Closure $message + * @return self + */ + public function deleteSuccessMessage($message): self + { + $this->deleteSuccessMessage = $message; + return $this; + } + + /** + * Get the delete success message + * + * @return string + */ + public function getDeleteSuccessMessage(): string + { + return $this->evaluateClosureValue($this->deleteSuccessMessage); + } + + /** + * Set the empty message + * + * @param string|Closure $message + * @return self + */ + public function emptyMessage($message): self + { + $this->emptyMessage = $message; + return $this; + } + + /** + * Get the empty message + * + * @return string + */ + public function getEmptyMessage(): string + { + return $this->evaluateClosureValue($this->emptyMessage); + } + + /** + * Set footer actions + * + * @param array|Closure $actions + * @return self + */ + public function footerActions($actions): self + { + $this->footerActions = $actions; + return $this; + } + + /** + * Get footer actions + * + * @return array + */ + public function getFooterActions(): array + { + return $this->evaluateClosureValue($this->footerActions); + } + + /** + * Set whether this is a full page table + * + * @param bool|Closure $fullPage + * @return self + */ + public function fullPage($fullPage): self + { + $this->fullPage = $fullPage; + return $this; + } + + /** + * Get whether this is a full page table + * + * @return bool + */ + public function getFullPage(): bool + { + return $this->evaluateClosureValue($this->fullPage); + } + + /** + * Set whether this is a full pane table + * + * @param bool|Closure $fullPane + * @return self + */ + public function fullPane($fullPane): self + { + $this->fullPane = $fullPane; + return $this; + } + + /** + * Get whether this is a full pane table + * + * @return bool + */ + public function getFullPane(): bool + { + return $this->evaluateClosureValue($this->fullPane); + } + + /** + * Set item labels for pagination + * + * @param string|Closure $singular + * @param string|Closure $plural + * @return self + */ + public function itemLabels($singular, $plural): self + { + $this->itemLabels = [ + 'singular' => $singular, + 'plural' => $plural, + ]; + return $this; + } + + /** + * Get item labels for pagination + * + * @return array + */ + public function getItemLabels(): array + { + $itemLabels = $this->evaluateClosureValue($this->itemLabels); + + if (isset($itemLabels['singular']) && $itemLabels['singular'] instanceof Closure) { + $itemLabels['singular'] = $this->evaluateClosureValue($itemLabels['singular']); + } + + if (isset($itemLabels['plural']) && $itemLabels['plural'] instanceof Closure) { + $itemLabels['plural'] = $this->evaluateClosureValue($itemLabels['plural']); + } + + return $itemLabels; + } + + /** + * Set minimum items + * + * @param int|Closure $min + * @return self + */ + public function minItems($min): self + { + $this->minItems = $min; + return $this; + } + + /** + * Get minimum items + * + * @return int|null + */ + public function getMinItems(): ?int + { + return $this->evaluateClosureValue($this->minItems); + } + + /** + * Set move to page action URL + * + * @param string|Closure $url + * @return self + */ + public function moveToPageAction($url): self + { + $this->moveToPageAction = $url; + return $this; + } + + /** + * Get move to page action URL + * + * @return string|null + */ + public function getMoveToPageAction(): ?string + { + return $this->evaluateClosureValue($this->moveToPageAction); + } + + /** + * Set no search results message + * + * @param string|Closure $message + * @return self + */ + public function noSearchResults($message): self + { + $this->noSearchResults = $message; + return $this; + } + + /** + * Get no search results message + * + * @return string + */ + public function getNoSearchResults(): string + { + return $this->evaluateClosureValue($this->noSearchResults); + } + + /** + * Set whether to add padding around the table + * + * @param bool|Closure $padded + * @return self + */ + public function padded($padded): self + { + $this->padded = $padded; + return $this; + } + + /** + * Get whether to add padding around the table + * + * @return bool + */ + public function getPadded(): bool + { + return $this->evaluateClosureValue($this->padded); + } + + /** + * Set paginated reorder action URL + * + * @param string|Closure $url + * @return self + */ + public function paginatedReorderAction($url): self + { + $this->paginatedReorderAction = $url; + return $this; + } + + /** + * Get paginated reorder action URL + * + * @return string|null + */ + public function getPaginatedReorderAction(): ?string + { + return $this->evaluateClosureValue($this->paginatedReorderAction); + } + + /** + * Set results per page + * + * @param int|Closure $perPage + * @return self + */ + public function perPage($perPage): self + { + $this->perPage = $perPage; + return $this; + } + + /** + * Get results per page + * + * @return int|null + */ + public function getPerPage(): ?int + { + return $this->evaluateClosureValue($this->perPage); + } + + /** + * Set reorder action URL + * + * @param string|Closure $url + * @return self + */ + public function reorderAction($url): self + { + $this->reorderAction = $url; + return $this; + } + + /** + * Get reorder action URL + * + * @return string|null + */ + public function getReorderAction(): ?string + { + return $this->evaluateClosureValue($this->reorderAction); + } + + /** + * Set reorder fail message + * + * @param string|Closure $message + * @return self + */ + public function reorderFailMessage($message): self + { + $this->reorderFailMessage = $message; + return $this; + } + + /** + * Get reorder fail message + * + * @return string + */ + public function getReorderFailMessage(): string + { + return $this->evaluateClosureValue($this->reorderFailMessage); + } + + /** + * Set reorder success message + * + * @param string|Closure $message + * @return self + */ + public function reorderSuccessMessage($message): self + { + $this->reorderSuccessMessage = $message; + return $this; + } + + /** + * Get reorder success message + * + * @return string + */ + public function getReorderSuccessMessage(): string + { + return $this->evaluateClosureValue($this->reorderSuccessMessage); + } + + /** + * Set whether to show search + * + * @param bool|Closure $show + * @return self + */ + public function search($show): self + { + $this->search = $show; + return $this; + } + + /** + * Get whether to show search + * + * @return bool + */ + public function getSearch(): bool + { + return $this->evaluateClosureValue($this->search); + } + + /** + * Set search clear button text + * + * @param string|Closure $text + * @return self + */ + public function searchClear($text): self + { + $this->searchClear = $text; + return $this; + } + + /** + * Get search clear button text + * + * @return string + */ + public function getSearchClear(): string + { + return $this->evaluateClosureValue($this->searchClear); + } + + /** + * Set search parameters + * + * @param array|Closure $params + * @return self + */ + public function searchParams($params): self + { + $this->searchParams = $params; + return $this; + } + + /** + * Get search parameters + * + * @return array + */ + public function getSearchParams(): array + { + return $this->evaluateClosureValue($this->searchParams); + } + + /** + * Set search placeholder + * + * @param string|Closure $placeholder + * @return self + */ + public function searchPlaceholder($placeholder): self + { + $this->searchPlaceholder = $placeholder; + return $this; + } + + /** + * Get search placeholder + * + * @return string + */ + public function getSearchPlaceholder(): string + { + return $this->evaluateClosureValue($this->searchPlaceholder); + } + + /** + * Set table data (for data mode) + * + * @param array|Closure $data + * @return self + */ + public function tableData($data): self + { + $this->tableData = $data; + return $this; + } + + /** + * Get table data (for data mode) + * + * @return array|null + */ + public function getTableData(): ?array + { + return $this->evaluateClosureValue($this->tableData); + } + + /** + * Set table data endpoint (for API mode) + * + * @param string|Closure $endpoint + * @return self + */ + public function tableDataEndpoint($endpoint): self + { + $this->tableDataEndpoint = $endpoint; + return $this; + } + + /** + * Get table data endpoint (for API mode) + * + * @return string|null + */ + public function getTableDataEndpoint(): ?string + { + return $this->evaluateClosureValue($this->tableDataEndpoint); + } + + /** + * Set event callback + * + * @param string $event Event name (e.g., 'onSelect', 'onData', etc.) + * @param callable $callback + * @return self + */ + public function on(string $event, callable $callback): self + { + $this->eventCallbacks[$event] = $callback; + return $this; + } + + /** + * Get event callbacks + * + * @return array + */ + public function getEventCallbacks(): array + { + return $this->eventCallbacks; + } + + /** + * Set state parameters that will be passed to closures + * + * @param array $state + * @return self + */ + public function withState(array $state): self + { + $this->state = $state; + return $this; + } + + /** + * Get options JSON for JS initialization + * + * @return string + */ + public function getOptionsJson(): string + { + $options = $this->getOptions(); + return Json::encode($options); + } + + /** + * Get options array for JS initialization + * + * @return array + */ + public function getOptions(): array + { + $options = [ + 'actions' => $this->getActions(), + 'allowMultipleSelections' => $this->getAllowMultipleSelections(), + 'allowMultipleDeletions' => $this->getAllowMultipleDeletions(), + 'buttons' => $this->getButtons(), + 'checkboxes' => $this->getCheckboxes(), + 'columns' => $this->getColumns(), + 'container' => $this->getContainer(), + 'deleteAction' => $this->getDeleteAction(), + 'deleteConfirmationMessage' => $this->getDeleteConfirmationMessage(), + 'deleteFailMessage' => $this->getDeleteFailMessage(), + 'deleteSuccessMessage' => $this->getDeleteSuccessMessage(), + 'emptyMessage' => $this->getEmptyMessage(), + 'footerActions' => $this->getFooterActions(), + 'fullPage' => $this->getFullPage(), + 'fullPane' => $this->getFullPane(), + 'itemLabels' => $this->getItemLabels(), + 'minItems' => $this->getMinItems(), + 'moveToPageAction' => $this->getMoveToPageAction(), + 'noSearchResults' => $this->getNoSearchResults(), + 'padded' => $this->getPadded(), + 'paginatedReorderAction' => $this->getPaginatedReorderAction(), + 'perPage' => $this->getPerPage(), + 'reorderAction' => $this->getReorderAction(), + 'reorderFailMessage' => $this->getReorderFailMessage(), + 'reorderSuccessMessage' => $this->getReorderSuccessMessage(), + 'search' => $this->getSearch(), + 'searchClear' => $this->getSearchClear(), + 'searchParams' => $this->getSearchParams(), + 'searchPlaceholder' => $this->getSearchPlaceholder(), + 'tableData' => $this->getTableData(), + 'tableDataEndpoint' => $this->getTableDataEndpoint(), + ]; + + // Add callback functions + if ($this->beforeDelete !== null) { + $options['beforeDelete'] = $this->beforeDelete; + } + + if ($this->checkboxStatus !== null) { + $options['checkboxStatus'] = $this->checkboxStatus; + } + + if ($this->deleteCallback !== null) { + $options['deleteCallback'] = $this->deleteCallback; + } + + // Add event callbacks + foreach ($this->eventCallbacks as $event => $callback) { + $options[$event] = $callback; + } + + // Filter out null values + return array_filter($options, function($value) { + return $value !== null; + }); + } + + /** + * Get JavaScript initialization code + * + * @return string + */ + public function getJsInit(): string + { + $options = $this->getOptionsJson(); + return "new Craft.VueAdminTable($options);"; + } + + /** + * Render the table + * + * @param string $containerId The ID of the container element + * @return string HTML and JS for the admin table + */ + public function render(string $containerId = 'admin-table'): string + { + // Set container if not already set + if ($this->container === null) { + $this->container = '#' . $containerId; + } + + $html = '
'; + $js = $this->getJsInit(); + + return $html . ''; + } + + /** + * Evaluate a value that might be a closure + * + * @param mixed $value The value or closure to evaluate + * @return mixed The evaluated value + */ + protected function evaluateClosureValue($value) + { + if ($value instanceof Closure) { + return $value($this->state ?: []); + } + + return $value; + } + + /** + * Create a column definition + * + * @param string $name The column name + * @param string|Closure $title The column title + * @param array $options Additional column options + * @return array The column definition + */ + public static function createColumn(string $name, $title, array $options = []): array + { + $column = array_merge([ + 'name' => $name, + 'title' => $title, + ], $options); + + return $column; + } + + /** + * Create a title column definition + * + * @param string|Closure $title The column title + * @param array $options Additional column options + * @return array The column definition + */ + public static function createTitleColumn($title, array $options = []): array + { + return self::createColumn('__slot:title', $title, $options); + } + + /** + * Create a handle column definition + * + * @param string|Closure $title The column title + * @param array $options Additional column options + * @return array The column definition + */ + public static function createHandleColumn($title, array $options = []): array + { + return self::createColumn('__slot:handle', $title, $options); + } + + /** + * Create a menu column definition + * + * @param string|Closure $title The column title + * @param array $options Additional column options + * @return array The column definition + */ + public static function createMenuColumn($title, array $options = []): array + { + return self::createColumn('__slot:menu', $title, $options); + } + + /** + * Create a detail column definition + * + * @param string|Closure $title The column title + * @param array $options Additional column options + * @return array The column definition + */ + public static function createDetailColumn($title, array $options = []): array + { + return self::createColumn('__slot:detail', $title, $options); + } +} diff --git a/src/helpers/ProjectConfigData.php b/src/helpers/ProjectConfigData.php index 5dbb174256..d5d4e8eb6f 100755 --- a/src/helpers/ProjectConfigData.php +++ b/src/helpers/ProjectConfigData.php @@ -21,7 +21,6 @@ use craft\commerce\services\ProductTypes; use craft\commerce\services\Stores; use craft\db\Query; -use craft\helpers\Json; /** * Class ProjectConfigData @@ -116,29 +115,11 @@ private static function _getProjectConfigKey(string $key): string */ private static function _rebuildGatewayProjectConfig(): array { - $gatewayData = (new Query()) - ->select(['*']) - ->from([Table::GATEWAYS]) - ->where(['isArchived' => false]) - ->all(); - - $configData = []; - - foreach ($gatewayData as $gatewayRow) { - $settings = Json::decodeIfJson($gatewayRow['settings']); - $configData[$gatewayRow['uid']] = [ - 'name' => $gatewayRow['name'], - 'handle' => $gatewayRow['handle'], - 'type' => $gatewayRow['type'], - 'settings' => $settings, - 'sortOrder' => (int)$gatewayRow['sortOrder'], - 'paymentType' => $gatewayRow['paymentType'], - 'isFrontendEnabled' => (bool)$gatewayRow['isFrontendEnabled'], - ]; + $data = []; + foreach (Plugin::getInstance()->getGateways()->getAllGateways() as $gateway) { + $data[$gateway->uid] = $gateway->getConfig(); } - - - return $configData; + return $data; } /** diff --git a/src/migrations/Install.php b/src/migrations/Install.php index 374f953d8d..52673ec538 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -297,6 +297,7 @@ public function createTables(): void 'settings' => $this->text(), 'paymentType' => $this->enum('paymentType', ['authorize', 'purchase'])->notNull()->defaultValue('purchase'), 'isFrontendEnabled' => $this->string(500)->notNull()->defaultValue('1'), + 'orderCondition' => $this->text(), 'isArchived' => $this->boolean()->notNull()->defaultValue(false), 'dateArchived' => $this->dateTime(), 'sortOrder' => $this->integer(), @@ -1425,6 +1426,7 @@ private function _defaultGateways(): void 'name' => 'Dummy', 'handle' => 'dummy', 'isFrontendEnabled' => true, + 'orderCondition' => [], 'isArchived' => false, ]; $gateway = new Dummy($data); diff --git a/src/migrations/m250301_120000_add_gateway_order_condition.php b/src/migrations/m250301_120000_add_gateway_order_condition.php new file mode 100644 index 0000000000..315cc920f3 --- /dev/null +++ b/src/migrations/m250301_120000_add_gateway_order_condition.php @@ -0,0 +1,50 @@ +addColumn(Table::GATEWAYS, 'orderCondition', $this->text()); + + $gateways = (new Query()) + ->select(['id']) + ->from(Table::GATEWAYS) + ->all(); + + foreach ($gateways as $gateway) { + $orderCondition = [ + 'class' => 'craft\\commerce\\elements\\conditions\\orders\\GatewayOrderCondition', + 'conditionRules' => [], + ]; + + $this->update(Table::GATEWAYS, + ['orderCondition' => json_encode($orderCondition)], + ['id' => $gateway['id']] + ); + } + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m250301_120000_add_gateway_order_condition cannot be reverted.\n"; + return false; + } +} diff --git a/src/plugin/Routes.php b/src/plugin/Routes.php index 7361d6bd6a..1488add07f 100644 --- a/src/plugin/Routes.php +++ b/src/plugin/Routes.php @@ -50,6 +50,9 @@ private function _registerCpRoutes(): void $event->rules['commerce/products/{{ 'No data available for the selected time period.'|t('commerce') }}
+| {{ column.label }} | + {% endfor %} +
|---|
| + {% if column.type == 'money' %} + {{ row[column.value]|currency }} + {% elseif column.type == 'date' %} + {{ row[column.value] is defined ? row[column.value]|date('short') : '' }} + {% elseif column.type == 'percent' %} + {{ row[column.value] }}% + {% else %} + {{ row[column.value] }} + {% endif %} + | + {% endfor %} +