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//new'] = 'commerce/products/create'; $event->rules['commerce/products//'] = 'elements/edit'; + $event->rules['commerce/reporting'] = 'commerce/reporting/index'; + $event->rules['commerce/reporting/'] = 'commerce/reporting/view'; + $event->rules['commerce/subscriptions'] = 'commerce/subscriptions/index'; $event->rules['commerce/subscriptions/'] = 'commerce/subscriptions/index'; $event->rules['commerce/subscriptions/'] = 'commerce/subscriptions/edit'; diff --git a/src/plugin/Services.php b/src/plugin/Services.php index 2c350cfbf2..47ef3ae570 100644 --- a/src/plugin/Services.php +++ b/src/plugin/Services.php @@ -34,6 +34,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; @@ -395,6 +396,17 @@ public function getPurchasables(): Purchasables return $this->get('purchasables'); } + /** + * Returns the reports service + * + * @return Reports The reports service + * @throws InvalidConfigException + */ + public function getReports(): Reports + { + return $this->get('reports'); + } + /** * Returns the sales service * diff --git a/src/records/Gateway.php b/src/records/Gateway.php index 6ba945da3a..e5771f7fc4 100644 --- a/src/records/Gateway.php +++ b/src/records/Gateway.php @@ -20,6 +20,7 @@ * @property int $id * @property bool $isArchived * @property string $name + * @property string $orderCondition * @property string $paymentType * @property array $settings * @property int $sortOrder diff --git a/src/reports/AverageOrderValueOverTime.php b/src/reports/AverageOrderValueOverTime.php new file mode 100644 index 0000000000..a0721a01e1 --- /dev/null +++ b/src/reports/AverageOrderValueOverTime.php @@ -0,0 +1,154 @@ + Craft::t('commerce', 'Date'), + 'value' => 'date', + 'type' => 'date', + ], + [ + 'label' => Craft::t('commerce', 'Average Order Value'), + 'value' => 'averageOrderValue', + 'type' => 'money', + ], + [ + 'label' => Craft::t('commerce', 'Number of Orders'), + 'value' => 'orderCount', + 'type' => 'number', + ], + [ + 'label' => Craft::t('commerce', 'Total Revenue'), + 'value' => 'totalRevenue', + 'type' => 'money', + ], + ]; + } + + /** + * @inheritDoc + */ + public function getParams(): array + { + return [ + [ + 'type' => 'select', + 'label' => Craft::t('commerce', 'Group By'), + 'handle' => 'groupBy', + 'options' => [ + ['value' => 'day', 'label' => Craft::t('commerce', 'Day')], + ['value' => 'week', 'label' => Craft::t('commerce', 'Week')], + ['value' => 'month', 'label' => Craft::t('commerce', 'Month')], + ['value' => 'year', 'label' => Craft::t('commerce', 'Year')], + ], + 'default' => 'auto', + ], + [ + 'type' => 'number', + 'label' => Craft::t('commerce', 'Minimum Orders'), + 'handle' => 'minOrders', + 'default' => 0, + ], + ]; + } + + /** + * @inheritDoc + */ + public function getQuery(): Query + { + $startDate = $this->getStartDate(); + $endDate = $this->getEndDate(); + $params = $this->getParamValues(); + + // Determine the grouping (day, week, month, year) + $groupBy = $params['groupBy'] ?? 'auto'; + + // If grouping is set to auto, determine based on date range + if ($groupBy === 'auto') { + $dateInterval = $startDate->diff($endDate); + + if ($dateInterval->days <= 31) { + $groupBy = 'day'; + } elseif ($dateInterval->days <= 90) { + $groupBy = 'week'; + } elseif ($dateInterval->days <= 365) { + $groupBy = 'month'; + } else { + $groupBy = 'year'; + } + } + + // Set date format based on grouping + switch ($groupBy) { + case 'day': + $dateFormat = 'DATE_FORMAT(o.dateOrdered, \'%Y-%m-%d\')'; + break; + case 'week': + $dateFormat = 'DATE_FORMAT(o.dateOrdered, \'%x-W%v\')'; // ISO year and week + break; + case 'month': + $dateFormat = 'DATE_FORMAT(o.dateOrdered, \'%Y-%m\')'; + break; + case 'year': + $dateFormat = 'DATE_FORMAT(o.dateOrdered, \'%Y\')'; + break; + default: + $dateFormat = 'DATE_FORMAT(o.dateOrdered, \'%Y-%m-%d\')'; + } + + $query = (new Query()) + ->select([ + 'date' => $dateFormat, + 'totalRevenue' => 'SUM(o.totalPaid)', + 'orderCount' => 'COUNT(o.id)', + 'averageOrderValue' => 'ROUND(SUM(o.totalPaid) / COUNT(o.id), 2)', + ]) + ->from(['o' => Table::ORDERS]) + ->where([ + 'o.isCompleted' => true, + ]) + ->andWhere(['between', 'o.dateOrdered', + $startDate ? Db::prepareDateForDb($startDate) : Db::prepareDateForDb(new \DateTime('-30 days')), + $endDate ? Db::prepareDateForDb($endDate) : Db::prepareDateForDb(new \DateTime()), + ]) + ->groupBy(['date']); + + // Apply minimum orders filter if provided + if (isset($params['minOrders']) && $params['minOrders'] > 0) { + $query->having(['>=', 'COUNT(o.id)', $params['minOrders']]); + } + + return $query->orderBy(['date' => SORT_ASC]); + } +} diff --git a/src/reports/SalesByProduct.php b/src/reports/SalesByProduct.php new file mode 100644 index 0000000000..61e5349beb --- /dev/null +++ b/src/reports/SalesByProduct.php @@ -0,0 +1,130 @@ + Craft::t('commerce', 'Product Title'), + 'value' => 'productTitle', + 'type' => 'string', + ], + [ + 'label' => Craft::t('commerce', 'Quantity Sold'), + 'value' => 'quantitySold', + 'type' => 'number', + ], + [ + 'label' => Craft::t('commerce', 'Total Revenue'), + 'value' => 'totalRevenue', + 'type' => 'money', + ], + [ + 'label' => Craft::t('commerce', 'Average Price'), + 'value' => 'averagePrice', + 'type' => 'money', + ], + [ + 'label' => Craft::t('commerce', 'Order Count'), + 'value' => 'orderCount', + 'type' => 'number', + ], + ]; + } + + /** + * @inheritDoc + */ + public function getParams(): array + { + return [ + [ + 'type' => 'select', + 'label' => Craft::t('commerce', 'Sort By'), + 'handle' => 'sortBy', + 'options' => [ + ['value' => 'totalRevenue', 'label' => Craft::t('commerce', 'Total Revenue')], + ['value' => 'quantitySold', 'label' => Craft::t('commerce', 'Quantity Sold')], + ['value' => 'orderCount', 'label' => Craft::t('commerce', 'Order Count')], + ['value' => 'averagePrice', 'label' => Craft::t('commerce', 'Average Price')], + ['value' => 'productTitle', 'label' => Craft::t('commerce', 'Product Title')], + ], + 'default' => 'totalRevenue', + ], + [ + 'type' => 'select', + 'label' => Craft::t('commerce', 'Sort Direction'), + 'handle' => 'sortDir', + 'options' => [ + ['value' => 'desc', 'label' => Craft::t('commerce', 'Descending')], + ['value' => 'asc', 'label' => Craft::t('commerce', 'Ascending')], + ], + 'default' => 'desc', + ], + ]; + } + + /** + * @inheritDoc + */ + public function getQuery(): Query + { + $startDate = $this->getStartDate(); + $endDate = $this->getEndDate(); + $params = $this->getParamValues(); + + $query = (new Query()) + ->select([ + 'productTitle' => 'SUBSTRING_INDEX(li.description, " - ", 1)', // Extract just the product title before any variant info + 'quantitySold' => 'SUM(li.qty)', + 'totalRevenue' => 'SUM(li.total)', + 'averagePrice' => 'ROUND(SUM(li.total) / SUM(li.qty), 2)', + 'orderCount' => 'COUNT(DISTINCT o.id)', + ]) + ->from(['li' => Table::LINEITEMS]) + ->innerJoin(['o' => Table::ORDERS], '[[o.id]] = [[li.orderId]]') + ->where([ + 'o.isCompleted' => true, + ]) + ->andWhere(['between', 'o.dateOrdered', + $startDate ? Db::prepareDateForDb($startDate) : Db::prepareDateForDb(new \DateTime('-30 days')), + $endDate ? Db::prepareDateForDb($endDate) : Db::prepareDateForDb(new \DateTime()), + ]) + ->andWhere(['not', ['li.purchasableId' => null]]) // Ensure we're only looking at actual product line items + ->groupBy(['productTitle']); + + // Apply sorting + $sortBy = $params['sortBy'] ?? 'totalRevenue'; + $sortDir = ($params['sortDir'] ?? 'desc') === 'desc' ? SORT_DESC : SORT_ASC; + + return $query->orderBy([$sortBy => $sortDir]); + } +} diff --git a/src/reports/SalesBySku.php b/src/reports/SalesBySku.php new file mode 100644 index 0000000000..a9054493ea --- /dev/null +++ b/src/reports/SalesBySku.php @@ -0,0 +1,130 @@ + Craft::t('commerce', 'SKU'), + 'value' => 'sku', + 'type' => 'string', + ], + [ + 'label' => Craft::t('commerce', 'Description'), + 'value' => 'description', + 'type' => 'string', + ], + [ + 'label' => Craft::t('commerce', 'Quantity Sold'), + 'value' => 'quantitySold', + 'type' => 'number', + ], + [ + 'label' => Craft::t('commerce', 'Total Revenue'), + 'value' => 'totalRevenue', + 'type' => 'money', + ], + [ + 'label' => Craft::t('commerce', 'Average Price'), + 'value' => 'averagePrice', + 'type' => 'money', + ], + ]; + } + + /** + * @inheritDoc + */ + public function getParams(): array + { + return [ + [ + 'type' => 'select', + 'label' => Craft::t('commerce', 'Sort By'), + 'handle' => 'sortBy', + 'options' => [ + ['value' => 'quantitySold', 'label' => Craft::t('commerce', 'Quantity Sold')], + ['value' => 'totalRevenue', 'label' => Craft::t('commerce', 'Total Revenue')], + ['value' => 'averagePrice', 'label' => Craft::t('commerce', 'Average Price')], + ['value' => 'sku', 'label' => Craft::t('commerce', 'SKU')], + ], + 'default' => 'quantitySold', + ], + [ + 'type' => 'select', + 'label' => Craft::t('commerce', 'Sort Direction'), + 'handle' => 'sortDir', + 'options' => [ + ['value' => 'desc', 'label' => Craft::t('commerce', 'Descending')], + ['value' => 'asc', 'label' => Craft::t('commerce', 'Ascending')], + ], + 'default' => 'desc', + ], + ]; + } + + /** + * @inheritDoc + */ + public function getQuery(): Query + { + $startDate = $this->getStartDate(); + $endDate = $this->getEndDate(); + $params = $this->getParamValues(); + + $query = (new Query()) + ->select([ + 'sku' => 'li.sku', + 'description' => 'ANY_VALUE(li.description)', // Use ANY_VALUE to satisfy ONLY_FULL_GROUP_BY + 'quantitySold' => 'SUM(li.qty)', + 'totalRevenue' => 'SUM(li.total)', + 'averagePrice' => 'ROUND(SUM(li.total) / SUM(li.qty), 2)', + ]) + ->from(['li' => Table::LINEITEMS]) + ->innerJoin(['o' => Table::ORDERS], '[[o.id]] = [[li.orderId]]') + ->where([ + 'o.isCompleted' => true, + ]) + ->andWhere(['between', 'o.dateOrdered', + $startDate ? Db::prepareDateForDb($startDate) : Db::prepareDateForDb(new \DateTime('-30 days')), + $endDate ? Db::prepareDateForDb($endDate) : Db::prepareDateForDb(new \DateTime()), + ]) + ->groupBy(['li.sku']); + + // No SKU filter + + // Apply sorting + $sortBy = $params['sortBy'] ?? 'quantitySold'; + $sortDir = ($params['sortDir'] ?? 'desc') === 'desc' ? SORT_DESC : SORT_ASC; + + return $query->orderBy([$sortBy => $sortDir]); + } +} diff --git a/src/services/Gateways.php b/src/services/Gateways.php index 6f8fbcea4b..40c3bb0270 100644 --- a/src/services/Gateways.php +++ b/src/services/Gateways.php @@ -34,7 +34,6 @@ use yii\base\InvalidConfigException; use yii\base\NotSupportedException; use yii\web\ServerErrorHttpException; -use function get_class; /** * Gateway service. @@ -111,7 +110,7 @@ public function getAllGatewayTypes(): array */ public function getAllCustomerEnabledGateways(): Collection { - return $this->getAllGateways()->where(fn(GatewayInterface $gateway) => $gateway->getIsFrontendEnabled()); + return $this->getAllGateways()->filter(fn(GatewayInterface $gateway) => $gateway->getIsFrontendEnabled()); } /** @@ -258,15 +257,7 @@ public function saveGateway(Gateway $gateway, bool $runValidation = true): bool if ($gateway->isArchived) { $configData = null; } else { - $configData = [ - 'name' => $gateway->name, - 'handle' => $gateway->handle, - 'type' => get_class($gateway), - 'settings' => $gateway->getSettings(), - 'sortOrder' => ($gateway->sortOrder ?? 99), - 'paymentType' => $gateway->paymentType, - 'isFrontendEnabled' => $gateway->getIsFrontendEnabled(false), - ]; + $configData = $gateway->getConfig(); } $configPath = self::CONFIG_GATEWAY_KEY . '.' . $gatewayUid; @@ -306,6 +297,7 @@ public function handleChangedGateway(ConfigEvent $event): void } $gatewayRecord->isFrontendEnabled = $data['isFrontendEnabled']; + $gatewayRecord->orderCondition = $data['orderCondition'] ?? null; $gatewayRecord->isArchived = false; $gatewayRecord->dateArchived = null; $gatewayRecord->uid = $gatewayUid; @@ -446,7 +438,7 @@ public function getGatewayOverrides(string $handle): ?array */ private function _createGatewayQuery(): Query { - return (new Query()) + $query = (new Query()) ->select([ 'dateArchived', 'handle', @@ -462,6 +454,14 @@ private function _createGatewayQuery(): Query ]) ->orderBy(['sortOrder' => SORT_ASC]) ->from([Table::GATEWAYS]); + + // TODO: remove after next breakpoint + $db = Craft::$app->getDb(); + if ($db->columnExists(Table::GATEWAYS, 'orderCondition')) { + $query->addSelect('orderCondition'); + } + + return $query; } /** diff --git a/src/services/Reports.php b/src/services/Reports.php new file mode 100644 index 0000000000..319721e3cc --- /dev/null +++ b/src/services/Reports.php @@ -0,0 +1,46 @@ + + * @since 5.x + */ +class Reports extends Component +{ + /** + * @var ?Collection + */ + private ?Collection $_allReports = null; + + public function getAllReports(): Collection + { + if ($this->_allReports === null) { + $this->_allReports = collect([ + new \craft\commerce\reports\SalesByProduct(), + new \craft\commerce\reports\SalesBySku(), + new \craft\commerce\reports\AverageOrderValueOverTime(), + ]); + } + + return $this->_allReports; + } + + public function getReportByHandle(string $handle): ?Report + { + return $this->getAllReports()->firstWhere(function($report) use ($handle) { + return $report->getHandle() === $handle; + }); + } +} diff --git a/src/templates/reporting/_index.twig b/src/templates/reporting/_index.twig new file mode 100644 index 0000000000..6e0c82061c --- /dev/null +++ b/src/templates/reporting/_index.twig @@ -0,0 +1,5 @@ +
+ +{% js %} +{{ adminTable.getJsInit()|raw }} +{% endjs %} diff --git a/src/templates/reporting/_view.twig b/src/templates/reporting/_view.twig new file mode 100644 index 0000000000..71a787faf9 --- /dev/null +++ b/src/templates/reporting/_view.twig @@ -0,0 +1,153 @@ +{% import "_includes/forms" as forms %} + +{% do view.registerAssetBundle("craft\\web\\assets\\cp\\CpAsset") %} +{% do view.registerAssetBundle("craft\\web\\assets\\timepicker\\TimepickerAsset") %} +{% do view.registerAssetBundle("craft\\web\\assets\\conditionbuilder\\ConditionBuilderAsset") %} + +{# Report Filter Panel #} + +
+
+ + + + + {% set dateRangeHtml %}
{% endset %} + + {{ forms.field({ + label: 'Date Range'|t('commerce'), + }, dateRangeHtml) }} +
+ + {% js %} + var options = { + selected: '{{ dateRange ?? 'custom' }}', + onChange: function(start, end, handle) { + var $startDate = $('#date-range-wrapper [data-start-date]'); + var $endDate = $('#date-range-wrapper [data-end-date]'); + var $dateRange = $('#date-range-wrapper [data-date-range]'); + $startDate.val(''); + $endDate.val(''); + $dateRange.val(''); + + if (start) { + $startDate.val(start.getFullYear() + '-' + (start.getMonth() + 1) + '-' + start.getDate()); + } + + if (end) { + $endDate.val(end.getFullYear() + '-' + (end.getMonth() + 1) + '-' + end.getDate()); + } + + if (handle) { + $dateRange.val(handle); + } + } + }; + + {% if dateRange == 'custom' and startDate %} + options['startDate'] = new Date({{ startDate|date('Y') }}, {{ startDate|date('m') - 1 }}, {{ startDate|date('d') }}); + {% endif %} + + {% if dateRange == 'custom' and endDate %} + options['endDate'] = new Date({{ endDate|date('Y') }}, {{ endDate|date('m') - 1 }}, {{ endDate|date('d') }}); + {% endif %} + + var $container = $('#date-range-wrapper [data-date-range-picker]'); + var $picker = Craft.ui.createDateRangePicker(options).appendTo($container); + {% endjs %} + + {# Display custom parameters if available #} + {% if params is defined and params|length > 0 %} + + {% for param in params %} + {% set value = paramValues[param.handle] ?? param.default ?? null %} + + {% if param.type == 'select' %} + {{ forms.selectField({ + id: param.handle, + name: param.handle, + label: param.label, + options: param.options, + value: value + }) }} + {% elseif param.type == 'text' %} + {{ forms.textField({ + id: param.handle, + name: param.handle, + label: param.label, + value: value + }) }} + {% elseif param.type == 'number' %} + {{ forms.textField({ + id: param.handle, + name: param.handle, + label: param.label, + type: 'number', + value: value + }) }} + {% elseif param.type == 'checkbox' %} + {{ forms.checkboxField({ + id: param.handle, + name: param.handle, + label: param.label, + checked: value ? true : false, + value: 1 + }) }} + {% endif %} + {% endfor %} + {% endif %} + + +
+ +
+ {% if reportData is empty %} + +
+

{{ 'No data available for the selected time period.'|t('commerce') }}

+
+ {% else %} +
+ + + + {% for column in report.columns %} + + {% endfor %} + + + + {% for row in reportData %} + + {% for column in report.columns %} + + {% endfor %} + + {% endfor %} + +
{{ column.label }}
+ {% 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 %} +
+
+ {% endif %} diff --git a/src/templates/settings/gateways/_edit.twig b/src/templates/settings/gateways/_edit.twig index 2755b9ca99..7664028318 100644 --- a/src/templates/settings/gateways/_edit.twig +++ b/src/templates/settings/gateways/_edit.twig @@ -98,8 +98,6 @@ {% endfor %} -
- {{ forms.booleanMenuField({ label: "Enabled for customers to select during checkout?"|t('commerce'), id: 'isFrontendEnabled', @@ -110,6 +108,18 @@ disabled: readOnly, }) }} + +
+ {% set orderConditionInput %} + {{ gateway.getOrderCondition().getBuilderHtml(readOnly)|raw }} + {% endset %} + + {{ forms.field({ + label: 'Match Order'|t('commerce'), + instructions: 'Create rules that allow this gateway to match the order.'|t('commerce'), + errors: gateway.getErrors('orderCondition'), + }, orderConditionInput) }} + {% endblock %} {% js %} diff --git a/src/templates/store-management/shipping/shippingmethods/_edit.twig b/src/templates/store-management/shipping/shippingmethods/_edit.twig index 7502bef0f8..0700ca1c0c 100644 --- a/src/templates/store-management/shipping/shippingmethods/_edit.twig +++ b/src/templates/store-management/shipping/shippingmethods/_edit.twig @@ -87,7 +87,7 @@ id: 'orderCondition', label: 'Match Order'|t('commerce'), errors: shippingMethod.getErrors('orderCondition'), - instructions: 'Conditions here are matched against an order before looking through the rules. This is useful if you want qualify a method’s availabililty early or if there are common conditions to all rules for this method.'|t('commerce'), + instructions: 'Conditions here are matched against an order 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.'|t('commerce'), }, orderConditionInput) }} {% if shippingMethod.id %} diff --git a/src/translations/en/commerce.php b/src/translations/en/commerce.php index ad37936c20..191e8644ac 100644 --- a/src/translations/en/commerce.php +++ b/src/translations/en/commerce.php @@ -180,7 +180,7 @@ 'Completed' => 'Completed', 'Completing order failed.' => 'Completing order failed.', 'Condition' => 'Condition', - 'Conditions here are matched against an order before looking through the rules. This is useful if you want qualify a method’s availabililty early or if there are common conditions to all rules for this method.' => 'Conditions here are matched against an order before looking through the rules. This is useful if you want qualify a method’s availabililty early or if there are common conditions to all rules for this method.', + 'Conditions here are matched against an order 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.' => 'Conditions here are matched against an order 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.', 'Conditions' => 'Conditions', 'Control Panel Settings' => 'Control Panel Settings', 'Control panel' => 'Control panel', @@ -628,6 +628,7 @@ 'Manage orders' => 'Manage orders', 'Manage payment currencies' => 'Manage payment currencies', 'Manage promotions' => 'Manage promotions', + 'Manage reporting' => 'Manage reporting', 'Manage shipping' => 'Manage shipping', 'Manage store settings' => 'Manage store settings', 'Manage subscription plans' => 'Manage subscription plans', @@ -738,6 +739,7 @@ 'No product available.' => 'No product available.', 'No product types exist yet.' => 'No product types exist yet.', 'No purchasable available.' => 'No purchasable available.', + 'No reports exist yet.' => 'No reports exist yet.', 'No sale exists with the ID “{id}”' => 'No sale exists with the ID “{id}”', 'No sales exist yet.' => 'No sales exist yet.', 'No shipping address' => 'No shipping address', @@ -926,6 +928,7 @@ 'Remove' => 'Remove', 'Removed' => 'Removed', 'Repeat Customers' => 'Repeat Customers', + 'Reporting' => 'Reporting', 'Reply To' => 'Reply To', 'Require Billing Address At Checkout' => 'Require Billing Address At Checkout', 'Require Coupon Code' => 'Require Coupon Code', diff --git a/tests/fixtures/FieldLayoutFixture.php b/tests/fixtures/FieldLayoutFixture.php index b924c92c0e..b734159ef3 100644 --- a/tests/fixtures/FieldLayoutFixture.php +++ b/tests/fixtures/FieldLayoutFixture.php @@ -16,15 +16,4 @@ class FieldLayoutFixture extends BaseFieldLayoutFixture public $dataFile = __DIR__ . '/data/field-layout.php'; public $modelClass = FieldLayout::class; - - - public function load(): void - { - parent::load(); // TODO: Change the autogenerated stub - } - - public function unload(): void - { - parent::unload(); // TODO: Change the autogenerated stub - } }