feat: update admin page

This commit is contained in:
2025-11-03 09:20:28 +03:00
parent 30b0108fe7
commit cd818d3356
94 changed files with 4729 additions and 1227 deletions

View File

@@ -1,8 +1,12 @@
<?php
use Bastion\ApplicationFactory;
use Cart\User;
use Openguru\OpenCartFramework\Http\Response as HttpResponse;
use Openguru\OpenCartFramework\Logger\LoggerInterface;
use Openguru\OpenCartFramework\Logger\OpenCartLogAdapter;
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
use Openguru\OpenCartFramework\Support\Arr;
$sysLibPath = rtrim(DIR_SYSTEM, '/') . '/library/oc_telegram_shop';
$basePath = rtrim(DIR_APPLICATION, '/') . '/..';
@@ -90,16 +94,11 @@ class ControllerExtensionModuleTgshop extends Controller
public function index(): void
{
$hasConfig = $this->config->get('module_tgshop_app_name') !== null;
if ($hasConfig) {
$this->updateConfigFromDefaults();
$this->cleanUpOldAssets();
$this->injectVueJs();
$this->config();
} else {
$this->init();
}
$this->cleanUpOldAssets();
$this->migrateFromOldSettings();
$this->removeLegacyFiles();
$this->injectVueJs();
$this->config();
}
private function config(): void
@@ -107,142 +106,78 @@ class ControllerExtensionModuleTgshop extends Controller
$data = [];
$this->document->setTitle($this->language->get('heading_title'));
if (($this->request->server['REQUEST_METHOD'] === 'POST') && $this->validate()) {
$postData = $this->request->post;
$postData['module_tgshop_mainpage_slider'] = [];
if (! empty($_POST['module_tgshop_mainpage_slider'])) {
$postData['module_tgshop_mainpage_slider'] = $_POST['module_tgshop_mainpage_slider'];
}
$this->model_setting_setting->editSetting('module_tgshop', $postData);
$this->session->data['success'] = $this->language->get('text_success');
$this->response->redirect(
$this->url->link(
'extension/module/tgshop',
'user_token=' . $this->session->data['user_token'] . '&type=module',
true
)
);
}
$this->baseData($data);
$data['order_statuses'] = $this->getOrderStatuses();
$data['customer_groups'] = $this->getCustomerGroups();
$data['themes'] = self::$themes;
$data['action'] = $this->url->link(
'extension/module/tgshop',
'user_token=' . $this->session->data['user_token'],
true
);
$data['settings'] = $this->getSettingsConfig();
$data['mainpage_slider'] = [];
$banners = $this->config->get('module_tgshop_mainpage_slider');
if ($banners) {
$banners = html_entity_decode($banners);
$data['mainpage_slider'] = $banners;
}
foreach ($data['settings'] as $configs) {
foreach ($configs as $key => $config) {
if ($config['type'] === 'image') {
if (isset($this->request->post[$key]) && is_file(DIR_IMAGE . $this->request->post[$key])) {
$data[$key] = $this->model_tool_image->resize($this->request->post[$key], 100, 100);
} elseif ($this->config->get($key) && is_file(DIR_IMAGE . $this->config->get($key))) {
$data[$key] = $this->model_tool_image->resize($this->config->get($key), 100, 100);
} else {
$data[$key] = $this->model_tool_image->resize('no_image.png', 100, 100);
}
} elseif ($config['type'] === 'products') {
$products = $this->request->post[$key] ?? $this->config->get($key) ?? [];
$data[$key] = [];
foreach ($products as $productId) {
$productItem = $this->model_catalog_product->getProduct($productId);
$data[$key][] = [
'product_id' => $productId,
'name' => $productItem['name'],
];
}
} elseif ($config['type'] === 'categories') {
$categories = $this->request->post[$key] ?? $this->config->get($key) ?? [];
$data[$key] = [];
foreach ($categories as $categoryId) {
$categoryItem = $this->model_catalog_category->getCategory($categoryId);
$data[$key][] = [
'category_id' => $categoryId,
'name' => $categoryItem['name'],
];
}
} elseif (isset($this->request->post[$key])) {
$data[$key] = $this->request->post[$key];
} else {
$data[$key] = $this->config->get($key);
}
}
}
$this->response->setOutput($this->load->view('extension/module/tgshop', $data));
}
public function init(): void
{
$data = [];
$this->baseData($data);
$data['action'] = $this->url->link(
'extension/module/tgshop/init',
'user_token=' . $this->session->data['user_token'],
true
);
if ($this->request->server['REQUEST_METHOD'] === 'POST') {
$defaults = $this->getDefaultConfig();
$this->model_setting_setting->editSetting('module_tgshop', $defaults);
$this->session->data['success'] = 'Инициализация модуля выполнена успешно.';
$this->response->redirect(
$this->url->link(
'extension/module/tgshop',
'user_token=' . $this->session->data['user_token'],
true
)
);
}
$this->response->setOutput($this->load->view('extension/module/tgshop_init', $data));
}
public function handle(): void
{
$app = ApplicationFactory::create([
'base_url' => HTTPS_SERVER,
'public_url' => HTTPS_CATALOG,
'telegram' => [
'bot_token' => $this->config->get('module_tgshop_bot_token'),
'chat_id' => $this->config->get('module_tgshop_chat_id'),
'owner_notification_template' => $this->config->get('module_tgshop_owner_notification_template'),
'customer_notification_template' => $this->config->get('module_tgshop_customer_notification_template'),
],
'db' => [
'host' => DB_HOSTNAME,
'database' => DB_DATABASE,
'username' => DB_USERNAME,
'password' => DB_PASSWORD,
'prefix' => DB_PREFIX,
'port' => DB_PORT,
],
'logs' => [
'path' => DIR_LOGS,
],
]);
try {
$json = $this->model_setting_setting->getSetting('module_telecart');
if (! isset($json['module_telecart_settings'])) {
$json['module_telecart_settings'] = [];
}
$items = Arr::mergeArraysRecursively($json['module_telecart_settings'], [
'app' => [
'shop_base_url' => HTTPS_CATALOG, // for catalog: HTTPS_SERVER, for admin: HTTPS_CATALOG
'language_id' => (int) $this->config->get('config_language_id'),
],
'logs' => [
'path' => DIR_LOGS,
],
'database' => [
'host' => DB_HOSTNAME,
'database' => DB_DATABASE,
'username' => DB_USERNAME,
'password' => DB_PASSWORD,
'prefix' => DB_PREFIX,
'port' => (int) DB_PORT,
],
'store' => [
'oc_store_id' => 0,
'oc_default_currency' => $this->config->get('config_currency'),
'oc_config_tax' => filter_var($this->config->get('config_tax'), FILTER_VALIDATE_BOOLEAN),
],
'orders' => [
'oc_customer_group_id' => (int) $this->config->get('config_customer_group_id'),
],
'telegram' => [
'mini_app_url' => rtrim(HTTPS_CATALOG, '/') . '/image/catalog/tgshopspa/#/',
],
]);
$app->bind(OcRegistryDecorator::class, fn() => new OcRegistryDecorator($this->registry));
$app = ApplicationFactory::create($items);
$app->bind(OcRegistryDecorator::class, fn() => new OcRegistryDecorator($this->registry));
$app
->withLogger(fn() => new OpenCartLogAdapter($this->log, 'TeleCartAdmin'))
->bootAndHandleRequest();
$app
->withLogger(fn() => new OpenCartLogAdapter(
$this->log,
'TeleCartAdmin',
$app->getConfigValue('app.app_debug')
? LoggerInterface::LEVEL_DEBUG
: LoggerInterface::LEVEL_WARNING,
))
->bootAndHandleRequest();
} catch (Exception $e) {
$this->log->write('[TELECART] Error: ' . $e->getMessage());
http_response_code(HttpResponse::HTTP_INTERNAL_SERVER_ERROR);
header('Content-Type: application/json');
echo json_encode([
'error' => 'Ошибка сервера. Приносим свои извинения за неудобства.',
], JSON_THROW_ON_ERROR);
}
}
protected function validate(): bool
@@ -251,16 +186,6 @@ class ControllerExtensionModuleTgshop extends Controller
$this->error['telecart_error_warning'] = $this->language->get('error_permission');
}
foreach ($this->getSettingsConfig() as $configs) {
foreach ($configs as $key => $config) {
if (($config['required'] ?? false) === true && ! $this->request->post[$key]) {
$this->error["error_$key"] = 'Поле "' . $this->language->get(
"lbl_$key"
) . '" обязательно для заполнения.';
}
}
}
return ! $this->error;
}
@@ -316,286 +241,6 @@ class ControllerExtensionModuleTgshop extends Controller
$data['user_token'] = $this->session->data['user_token'];
}
private function getDefaultConfig(): array
{
return [
'module_tgshop_status' => 1,
'module_tgshop_debug' => 0,
'module_tgshop_app_name' => $this->config->get('config_meta_title'),
'module_tgshop_app_icon' => $this->config->get('config_image') ?: $this->model_tool_image->resize(
'no_image.png',
100,
100
),
'module_tgshop_owner_notification_template' => <<<TEXT
*Новый заказ \#{order_id}* в магазине *{store_name}*
*Покупатель:* {customer}
*Email:* {email}
*Телефон:* {phone}
*IP:* {ip}
*Адрес доставки:*
{address}
*Комментарий:*
{comment}
*Сумма заказа:* {total}
*Дата оформления:* {created_at}
TEXT,
'module_tgshop_customer_notification_template' => <<<TEXT
Спасибо за Ваш заказ в магазине *{store_name}*
*Номер заказа* \#{order_id}
*Сумма заказа:* {total}
*Дата оформления:* {created_at}
Мы свяжемся с вами при необходимости\.
Хорошего дня\!
TEXT,
'module_tgshop_theme_light' => 'light',
'module_tgshop_theme_dark' => 'dark',
'module_tgshop_mainpage_products' => 'most_viewed',
'module_tgshop_order_customer_group_id' => 1,
'module_tgshop_order_default_status_id' => 1,
'module_tgshop_mini_app_url' => rtrim(HTTPS_CATALOG, '/') . '/image/catalog/tgshopspa/#/',
'module_tgshop_mainpage_categories' => 'latest10',
'module_tgshop_enable_store' => 1,
'module_tgshop_feature_coupons' => 0,
'module_tgshop_feature_vouchers' => 0,
'module_tgshop_text_no_more_products' => 'Это всё по текущему запросу. Попробуйте уточнить фильтры или поиск.',
'module_tgshop_text_empty_cart' => 'Ваша корзина пуста',
'module_tgshop_text_order_created_success' => 'Ваш заказ успешно оформлен и будет обработан в ближайшее время.',
'module_tgshop_mainpage_slider' => json_encode([
'is_enabled' => false,
'effect' => 'slide',
'pagination' => true,
'scrollbar' => false,
'free_mode' => false,
'space_between' => 30,
'autoplay' => false,
'loop' => false,
'slides' => [],
], JSON_THROW_ON_ERROR),
'module_tgshop_yandex_metrika' => '',
'module_tgshop_chat_id' => '',
'module_tgshop_bot_token' => '',
];
}
private function getSettingsConfig(): array
{
$ocCouponsLink = $this->url->link(
'marketing/coupon',
'user_token=' . $this->session->data['user_token'],
true
);
$ocVouchersLink = $this->url->link(
'sale/voucher',
'user_token=' . $this->session->data['user_token'],
true
);
return [
'general' => [
'module_tgshop_status' => [
'type' => 'select',
'options' => [
0 => 'Выключено',
1 => 'Включено',
],
'help' => 'Если выключено, покупатели в Telegram увидят сообщение, что магазин временно закрыт. Заказы и просмотр товаров будут недоступны.',
],
'module_tgshop_app_name' => [
'type' => 'text',
'placeholder' => 'Без названия',
'help' => <<<TEXT
Отображается в заголовке Telegram Mini App при запуске, а также используется как подпись
под иконкой, если пользователь добавит приложение на главный экран своего устройства.
Рекомендуется короткое и понятное название (до 20 символов).
Если оставить пустым, то название выводиться не будет.
TEXT,
],
'module_tgshop_app_icon' => [
'type' => 'image',
'help' => <<<TEXT
Изображение, которое будет отображаться в Telegram Mini App и на рабочем столе устройства,
если пользователь добавит приложение как ярлык. Рекомендуется использовать квадратное изображение PNG или SVG,
размером 32×32 пикселей.
TEXT,
],
'module_tgshop_theme_light' => [
'type' => 'select',
'options' => static::$themes,
'help' => 'Выберите стиль, который будет использоваться при отображении вашего магазина в Telegram для дневного режима. <a href="https://daisyui.com/docs/themes/#list-of-themes" target="_blank">Посмотреть как выглядят темы</a>',
],
'module_tgshop_theme_dark' => [
'type' => 'select',
'options' => static::$themes,
'help' => 'Выберите стиль, который будет использоваться при отображении вашего магазина в Telegram для ночного режима. <a href="https://daisyui.com/docs/themes/#list-of-themes" target="_blank">Посмотреть как выглядят темы</a>',
],
'module_tgshop_debug' => [
'type' => 'select',
'options' => [
0 => 'Выключено',
1 => 'Включено',
],
'help' => 'Режим разработчика. Рекомендуется включать только по необходимости. В остальных случаях, для нормальной работы магазина, должен быть выключен.',
],
],
'telegram' => [
'module_tgshop_mini_app_url' => [
'type' => 'text_readonly',
'help' => <<<HTML
Это прямая ссылка на ваш Telegram Mini App. Скопируйте её в точности, как указано и добавьте в настройки Telegram-бота.
<p class="text-warning">⚠️ Важно: ссылка обязательно должна заканчиваться на /#/ — иначе приложение не загрузится.</p>
HTML,
],
'module_tgshop_bot_token' => [
'type' => 'bot_token',
'placeholder' => 'Введите токен от телеграм бота',
'help' => <<<TEXT
Токен, полученный при создании бота через @BotFather.
Он используется для взаимодействия модуля с Telegram API.
TEXT,
],
'module_tgshop_chat_id' => [
'type' => 'chatid',
'placeholder' => 'Введите Chat ID',
'help' => <<<TEXT
Идентификатор Telegram-чата, куда будут отправляться уведомления о новых заказах.
Если оставить поле пустым, уведомления отправляться не будут.
TEXT,
],
'module_tgshop_owner_notification_template' => [
'type' => 'tg_message_template',
'placeholder' => 'Введите текст уведомления',
'rows' => 15,
'help' => 'Введите шаблон сообщения для Telegram-уведомлений о новом заказе владельцу магазина.',
],
'module_tgshop_customer_notification_template' => [
'type' => 'tg_message_template',
'placeholder' => 'Введите текст уведомления',
'rows' => 15,
'help' => 'Введите шаблон сообщения для Telegram-уведомлений о новом заказе покупателю.',
],
],
'statistics' => [
'module_tgshop_yandex_metrika' => [
'type' => 'textarea',
'placeholder' => 'Вставьте код счётчика Яндекс Метрики.',
'rows' => 15,
'help' => 'Для проверки интеграции через кнопку "Проверить" в интерфейсе Яндекс Метрики, необходимо сначала включить "Режим разработчика" на вкладке "Общие".'
],
],
'shop' => [
'module_tgshop_enable_store' => [
'type' => 'select',
'options' => [
0 => 'Выключено',
1 => 'Включено',
],
'help' => <<<HTML
Если опция <strong>включена</strong> пользователи смогут оформлять заказы прямо в Telegram-магазине. <br>
Если <strong>выключена</strong> оформление заказов будет недоступно. Вместо кнопки «Добавить в корзину» пользователи увидят кнопку «Перейти к товару», которая откроет страницу товара на вашем сайте. В этом режиме Telecart работает как каталог.
HTML,
],
'module_tgshop_mainpage_products' => [
'type' => 'select',
'options' => [
'most_viewed' => 'Популярные товары',
'latest' => 'Последние сверху',
'featured' => 'Избранные товары (задать в поле ниже)',
],
'help' => 'Выберите, какие товары показывать на главной странице магазина в Telegram. Это влияет на первую видимую секцию каталога для пользователя.',
],
'module_tgshop_featured_products' => [
'type' => 'products',
'help' => 'На главной странице будут отображаться избранные товары, если вы выберете этот вариант в настройке “Товары на главной”. Если товары не выбраны, то будут показаны популярные товары.',
],
'module_tgshop_mainpage_categories' => [
'type' => 'select',
'options' => [
'no_categories' => 'Отображать только кнопку "Каталог"',
'latest10' => 'Последние 10 категорий',
'featured' => 'Избранные категории (задать в поле ниже)',
],
'help' => 'Выберите, какие категории показывать на главной странице магазина в Telegram. Это влияет на первую видимую секцию каталога для пользователя.',
],
'module_tgshop_featured_categories' => [
'type' => 'categories',
'help' => 'На главной странице будут отображаться эти категории, если вы выберете этот вариант в настройке “Категории на главной”.',
],
'module_tgshop_feature_coupons' => [
'type' => 'select',
'options' => [
0 => 'Выключено',
1 => 'Включено',
],
'help' => <<<HTML
Позволяет использовать стандартные <a href="{$ocCouponsLink}" target="_blank">купоны OpenCart</a> для предоставления скидок при оформлении заказа.
HTML,
],
'module_tgshop_feature_vouchers' => [
'type' => 'select',
'options' => [
0 => 'Выключено',
1 => 'Включено',
],
'help' => <<<HTML
Позволяет покупателям использовать <a href="{$ocVouchersLink}" target="_blank">подарочные сертификаты OpenCart</a> при оформлении заказа.
HTML,
],
],
'orders' => [
'module_tgshop_order_default_status_id' => [
'type' => 'select',
'options' => $this->getOrderStatuses(),
'help' => 'Статус, с которым будут создаваться заказы через Telegram по умолчанию.',
],
'module_tgshop_order_customer_group_id' => [
'hidden' => true,
'type' => 'select',
'options' => $this->getCustomerGroups(),
'help' => 'Группа покупателей, которая будет назначена для заказов, оформленных через Telegram-магазин.',
],
],
'texts' => [
'module_tgshop_text_no_more_products' => [
'type' => 'text',
'placeholder' => 'Это всё по текущему запросу. Попробуйте уточнить фильтры или поиск.',
'help' => 'Текст, отображаемый в конце списка, когда больше нет доступных товаров. Покупатель дошел до конца списка.',
],
'module_tgshop_text_empty_cart' => [
'type' => 'text',
'placeholder' => 'Ваша корзина пуста',
'help' => 'Текст, отображаемый на странице просмотра корзины, если в ней нет товаров.',
],
'module_tgshop_text_order_created_success' => [
'type' => 'text',
'placeholder' => 'Ваш заказ успешно оформлен и будет обработан в ближайшее время.',
'help' => 'Текст, отображаемый при успешном создании заказа.',
],
],
];
}
private function getCustomerGroups(): array
{
$map = [];
@@ -607,7 +252,7 @@ HTML,
return $map;
}
private function getOrderStatuses()
private function getOrderStatuses(): array
{
$statuses = $this->model_localisation_order_status->getOrderStatuses();
$map = [];
@@ -619,45 +264,6 @@ HTML,
return $map;
}
private function updateConfigFromDefaults(): void
{
$defaults = $this->getDefaultConfig();
$settings = $this->model_setting_setting->getSetting('module_tgshop');
$diff = [];
foreach ($defaults as $key => $value) {
if (! array_key_exists($key, $settings)) {
$diff[$key] = $defaults[$key];
}
}
if ($diff) {
$settings = array_merge($settings, $diff);
$this->model_setting_setting->editSetting('module_tgshop', $settings);
$this->log->write('[TELECART] Выполнено обновление настроек по умолчанию для модуля.');
$this->session->data['success'] = 'Выполнено обновление настроек по умолчанию для модуля.';
foreach ($diff as $key => $value) {
$this->config->set($key, $value);
}
}
$diffToDelete = [];
foreach ($settings as $key => $value) {
if (! array_key_exists($key, $defaults)) {
$diffToDelete[] = $key;
}
}
if ($diffToDelete) {
$keys = implode(', ', array_map(function ($key) {
return "'{$key}'";
}, $diffToDelete));
$this->db->query("DELETE FROM " . DB_PREFIX . "setting WHERE `key` IN ($keys)");
$this->log->write('[TELECART] Удалены старые конфиги: ' . $keys);
}
}
private function cleanUpOldAssets(): void
{
$spaPath = rtrim(DIR_IMAGE, '/') . '/catalog/tgshopspa';
@@ -733,4 +339,95 @@ HTML,
throw new RuntimeException('Unable to load Vuejs frontend.');
}
}
private function migrateFromOldSettings(): void
{
$legacySettings = $this->model_setting_setting->getSetting('module_tgshop');
if (! $legacySettings) {
return;
}
$newSettings = $this->model_setting_setting->getSetting('module_telecart');
static $mapLegacyToNewSettings = [
'module_tgshop_app_icon' => 'app.app_icon',
'module_tgshop_theme_light' => 'app.theme_light',
'module_tgshop_bot_token' => 'telegram.bot_token',
'module_tgshop_status' => 'app.app_enabled',
'module_tgshop_app_name' => 'app.app_name',
'module_tgshop_theme_dark' => 'app.theme_dark',
'module_tgshop_debug' => 'app.app_debug',
'module_tgshop_chat_id' => 'telegram.chat_id',
'module_tgshop_owner_notification_template' => 'telegram.owner_notification_template',
'module_tgshop_text_order_created_success' => 'texts.text_order_created_success',
'module_tgshop_enable_store' => 'store.enable_store',
'module_tgshop_mainpage_products' => 'store.mainpage_products',
'module_tgshop_yandex_metrika' => 'metrics.yandex_metrika_counter',
'module_tgshop_customer_notification_template' => 'telegram.customer_notification_template',
'module_tgshop_feature_vouchers' => 'store.feature_vouchers',
'module_tgshop_order_default_status_id' => 'orders.order_default_status_id',
'module_tgshop_feature_coupons' => 'store.feature_coupons',
'module_tgshop_mainpage_categories' => 'store.mainpage_categories',
'module_tgshop_text_no_more_products' => 'texts.text_no_more_products',
'module_tgshop_text_empty_cart' => 'texts.text_empty_cart',
];
if (! $newSettings) {
$data = [];
Arr::set($data, 'app.app_icon', $legacySettings['module_tgshop_app_icon']);
foreach ($mapLegacyToNewSettings as $key => $value) {
if (array_key_exists($key, $legacySettings)) {
if ($key === 'module_tgshop_status') {
$newValue = filter_var($legacySettings[$key], FILTER_VALIDATE_BOOLEAN);
} elseif ($key === 'module_tgshop_debug') {
$newValue = filter_var($legacySettings[$key], FILTER_VALIDATE_BOOLEAN);
} elseif ($key === 'module_tgshop_chat_id') {
$newValue = (int) $legacySettings[$key];
} elseif ($key === 'module_tgshop_enable_store') {
$newValue = filter_var($legacySettings[$key], FILTER_VALIDATE_BOOLEAN);
} elseif ($key === 'module_tgshop_order_default_status_id') {
$newValue = (int) $legacySettings[$key];
} elseif ($key === 'module_tgshop_feature_vouchers') {
$newValue = filter_var($legacySettings[$key], FILTER_VALIDATE_BOOLEAN);
} elseif ($key === 'module_tgshop_feature_coupons') {
$newValue = filter_var($legacySettings[$key], FILTER_VALIDATE_BOOLEAN);
} else {
$newValue = $legacySettings[$key];
}
Arr::set($data, $value, $newValue);
}
}
Arr::set(
$data,
'metrics.yandex_metrika_enabled',
! empty(trim($legacySettings['module_tgshop_yandex_metrika']))
);
$this->model_setting_setting->editSetting('module_telecart', [
'module_telecart_settings' => $data,
]);
$this->log->write('[TELECART] Выполнено обновление настроек с 1й версии модуля.');
$this->session->data['success'] = 'Выполнено обновление настроек с прошлой версии модуля.';
}
$this->model_setting_setting->deleteSetting('module_tgshop');
}
private function removeLegacyFiles(): void
{
$legacyFilesToRemove = [
DIR_TEMPLATE . '/extension/module/tgshop_init.twig',
];
foreach ($legacyFilesToRemove as $file) {
if (file_exists($file)) {
unlink($file);
$this->log->write('[TELECART] Удалён старый файл: ' . $file);
}
}
}
}

View File

@@ -2,11 +2,6 @@
<div id="content">
<div class="page-header">
<div class="container-fluid">
<div class="pull-right">
<button type="submit" form="form-module" data-toggle="tooltip" title="{{ button_save }}"
class="btn btn-primary"><i class="fa fa-save"></i></button>
<a href="{{ cancel }}" data-toggle="tooltip" title="{{ button_cancel }}" class="btn btn-default"><i
class="fa fa-reply"></i></a></div>
<h1>{{ heading_title }}</h1>
<ul class="breadcrumb">
{% for breadcrumb in breadcrumbs %}
@@ -27,438 +22,18 @@
<button type="button" class="close" data-dismiss="alert">&times;</button>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-pencil"></i> {{ text_edit }}</h3>
</div>
<div class="panel-body">
<form action="{{ action }}" method="post" enctype="multipart/form-data" id="form-module"
class="form-horizontal">
<ul class="nav nav-tabs">
{% for tabKey, tabItems in settings %}
<li{% if tabKey == 'general' %} class="active" {% endif %}>
<a href="#{{ tabKey }}" data-toggle="tab">
{% if attribute(_context, 'tab_' ~ tabKey) %}
{{ attribute(_context, 'tab_' ~ tabKey) }}
{% else %}
{{ 'tab_' ~ tabKey }}
{% endif %}
</a>
</li>
{% endfor %}
<li>
<a href="#banners" data-toggle="tab">
Баннеры
</a>
</li>
</ul>
<div class="tab-content">
{% for tabKey, tabItems in settings %}
<div class="tab-pane{%if tabKey == 'general' %} active{% endif %}" id="{{ tabKey }}">
{% for settingKey, item in tabItems %}
<div class="form-group{%if item['required'] %} required{% endif %}{% if item['hidden'] %} hidden{% endif %}">
<label class="col-sm-2 control-label" for="{{ settingKey }}">
{% if attribute(_context, 'lbl_' ~ settingKey) %}
{{ attribute(_context, 'lbl_' ~ settingKey) }}
{% else %}
{{ 'lbl_' ~ settingKey }}
{% endif %}
</label>
<div class="col-sm-10">
{# Select #}
{% if item['type'] == 'select' %}
<select name="{{ settingKey }}" id="{{ settingKey }}" class="form-control">
{% for key, value in item['options'] %}
<option value="{{ key }}" {% if key == attribute(_context, settingKey) %}selected="selected"{% endif %}>
{{ value }}
</option>
{% endfor %}
</select>
{# Text Input #}
{% elseif item['type'] == 'text' %}
<input type="text"
name="{{ settingKey }}"
value="{{ attribute(_context, settingKey) }}"
placeholder="{{ item['placeholder'] }}"
id="{{ settingKey }}"
class="form-control"
/>
{# Image #}
{% elseif item['type'] == 'image' %}
<a href="" id="thumb-image-{{ settingKey }}" data-toggle="image" class="img-thumbnail">
<img src="{{ attribute(_context, settingKey) }}"
data-placeholder="https://placehold.co/100x100?text=Удалено"
/>
</a>
<input type="hidden"
name="{{ settingKey }}"
value="{{ attribute(_context, settingKey) }}"
id="{{ settingKey }}"
/>
{# Textarea #}
{% elseif item['type'] == 'textarea' %}
<textarea name="{{ settingKey }}"
rows="{{ item['rows'] }}"
placeholder="{{ item['placeholder'] }}"
id="{{ settingKey }}"
class="form-control"
>{{ attribute(_context, settingKey) }}</textarea>
{# Products #}
{% elseif item['type'] == 'products' %}
<input type="text" value="" placeholder="Начните вводить название товара..." id="{{ settingKey }}-input" class="form-control"/>
<div id="{{ settingKey }}-list" class="well well-sm" style="height: 150px; overflow: auto;">
{% for product in attribute(_context, settingKey) %}
<div id="{{ settingKey }}-{{ product.product_id }}">
<i class="fa fa-minus-circle"></i> {{ product.name }}
<input type="hidden" name="{{ settingKey }}[]" value="{{ product.product_id }}"/>
</div>
{% endfor %}
</div>
<script>
$('#{{ settingKey }}-input').autocomplete({
'source': function(request, response) {
$.ajax({
url: 'index.php?route=catalog/product/autocomplete&user_token={{ user_token }}&filter_name=' + encodeURIComponent(request),
dataType: 'json',
success: function(json) {
response($.map(json, function(item) {
return {
label: item['name'],
value: item['product_id']
}
}));
}
});
},
'select': function(item) {
$('#{{ settingKey }}').val('');
$('#{{ settingKey }}-' + item['value']).remove();
$('#{{ settingKey }}-list').append('<div id="{{ settingKey }}-' + item['value'] + '"><i class="fa fa-minus-circle"></i> ' + item['label'] + '<input type="hidden" name="{{ settingKey }}[]" value="' + item['value'] + '" /></div>');
}
});
$('#{{ settingKey }}-list').delegate('.fa-minus-circle', 'click', function() {
$(this).parent().remove();
});
</script>
{% elseif item['type'] == 'categories' %}
<input type="text" value="" placeholder="Начните вводить название категории..." id="{{ settingKey }}-input" class="form-control"/>
<div id="{{ settingKey }}-list" class="well well-sm" style="height: 150px; overflow: auto;">
{% for category in attribute(_context, settingKey) %}
<div id="{{ settingKey }}-{{ category.category_id }}">
<i class="fa fa-minus-circle"></i> {{ category.name }}
<input type="hidden" name="{{ settingKey }}[]" value="{{ category.category_id }}"/>
</div>
{% endfor %}
</div>
<script>
$('#{{ settingKey }}-input').autocomplete({
'source': function(request, response) {
$.ajax({
url: 'index.php?route=catalog/category/autocomplete&user_token={{ user_token }}&filter_name=' + encodeURIComponent(request),
dataType: 'json',
success: function(json) {
response($.map(json, function(item) {
return {
label: item['name'],
value: item['category_id']
}
}));
}
});
},
'select': function(item) {
$('#{{ settingKey }}').val('');
$('#{{ settingKey }}-' + item['value']).remove();
$('#{{ settingKey }}-list').append('<div id="{{ settingKey }}-' + item['value'] + '"><i class="fa fa-minus-circle"></i> ' + item['label'] + '<input type="hidden" name="{{ settingKey }}[]" value="' + item['value'] + '" /></div>');
}
});
$('#{{ settingKey }}-list').delegate('.fa-minus-circle', 'click', function() {
$(this).parent().remove();
});
</script>
{# ChatID #}
{% elseif item['type'] == 'chatid' %}
{% if module_tgshop_bot_token %}
<div class="input-group">
<span class="input-group-btn">
<button id="{{ settingKey }}-btn" class="btn btn-primary" type="button">
<i class="fa fa-refresh"></i> Получить Chat ID
</button>
</span>
<input type="text"
name="{{ settingKey }}"
value="{{ attribute(_context, settingKey) }}"
placeholder="{{ item['placeholder'] }}"
id="{{ settingKey }}"
class="form-control"
/>
<script>
$('#{{ settingKey }}-btn').click(function () {
const $resultLabel = $('#{{ settingKey }}-result-label');
const telegramToken = $('#module_tgshop_bot_token').val().trim(); // fetch from input
if (! telegramToken) {
alert('Сначала введите Telegram Bot Token!');
return;
}
fetch('/admin/index.php?route=extension/module/tgshop/handle&api_action=getChatId&user_token={{ user_token }}')
.then(async (res) => {
const data = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(`Ошибка ${res.status}: ${data.message || res.statusText}`);
}
$('#{{ settingKey }}').val(data.data.chat_id);
$resultLabel
.text('✅ ChatID успешно получен и подставлен в поле. Не забудьте сохранить настройки!')
.css('color', 'green');
})
.catch(err => {
console.error(err);
alert(err);
});
});
</script>
</div>
<div id="{{ settingKey }}-result-label"></div>
<button class="btn btn-link btn-xs" type="button" data-toggle="collapse" data-target="#{{ settingKey }}-collapse" aria-expanded="false" aria-controls="collapseExample">
Инструкция как получить ChatID.
</button>
<div class="collapse" id="{{ settingKey }}-collapse">
<div class="well">
<p class="text-primary">Как получить Chat ID</p>
<ol>
<li>Убедитесь, что Telegram Bot Token введён выше.</li>
<li>Откройте вашего бота в Telegram и отправьте ему кодовое слово: `opencart_get_chatid`. Важно отправить именно такое сообщение, иначе не сработает.</li>
<li>Вернитесь сюда и нажмите кнопку «Получить Chat ID» — скрипт автоматически подставит его в поле ниже.</li>
</ol>
</div>
</div>
{% else %}
<div class="alert alert-warning">
<strong>BotToken</strong> не указан. Пожалуйста, введите корректный BotToken и сохраните настройки. После этого здесь станет доступна настройка ChatID.
</div>
{% endif %}
{% elseif item['type'] == 'tg_message_template' %}
<div style="margin-bottom: 10px;">
<textarea name="{{ settingKey }}"
rows="{{ item['rows'] }}"
placeholder="{{ item['placeholder'] }}"
id="{{ settingKey }}"
class="form-control"
>{{ attribute(_context, settingKey) }}</textarea>
</div>
<div>
<button class="btn btn-link" type="button" data-toggle="collapse" data-target="#{{ settingKey }}-collapse">
Документация
</button>
<button id="{{ settingKey }}-btn-test" type="button" class="btn btn-primary btn-sm">
<i class="fa fa-envelope"></i>
Отправить тестовое уведомление
</button>
</div>
<div class="collapse" id="{{ settingKey }}-collapse" style="margin-top: 15px">
<div class="well">
<p>Вы можете использовать переменные:</p>
<ul>
<li><code>{store_name}</code> — название магазина</li>
<li><code>{order_id}</code> — номер заказа</li>
<li><code>{customer}</code> — имя и фамилия покупателя</li>
<li><code>{email}</code> — email покупателя</li>
<li><code>{phone}</code> — телефон</li>
<li><code>{comment}</code> — комментарий к заказу</li>
<li><code>{address}</code> — адрес доставки</li>
<li><code>{total}</code> — сумма заказа</li>
<li><code>{ip}</code> — IP покупателя</li>
<li><code>{created_at}</code> — дата и время создания заказа</li>
</ul>
<p>Форматирование: поддерживается <a href="https://core.telegram.org/bots/api#markdownv2-style" target="_blank">*MarkdownV2* <i class="fa fa-external-link"></i></a>.</p>
<p>Символы, которые нужно экранировать в тексте:</p>
<pre>_ * [ ] ( ) ~ ` > # + - = | { } . !</pre>
<p>Каждый из них нужно экранировать обратным слэшем \, если он не используется для форматирования. Например вместо <code>Заказ #123</code> нужно писать <code>Заказ \#123</code>.</p>
</div>
</div>
<script>
$('#{{ settingKey }}-btn-test').click(function () {
const telegramToken = $('#module_tgshop_bot_token').val().trim();
if (! telegramToken) {
alert('Сначала введите Telegram Bot Token!');
return;
}
const chatId = $('#module_tgshop_chat_id').val().trim();
if (! chatId) {
alert('Сначала введите Chat ID!');
return;
}
const template = $('#{{ settingKey }}').val().trim();
if (! template) {
alert('Сначала задайте шаблон!');
return;
}
fetch('/index.php?route=extension/tgshop/handle&api_action=testTgMessage', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: telegramToken,
chat_id: chatId,
template: template
})
})
.then(res => res.json())
.then(response => {
alert(response.message || 'Уведомление успешно отправлено');
})
.catch(error => {
console.error(error);
alert('Ошибка при отправке тестового сообщения');
});
});
</script>
{% elseif item['type'] == 'text_readonly' %}
<input type="text"
readonly
name="{{ settingKey }}"
value="{{ attribute(_context, settingKey) }}"
id="{{ settingKey }}"
class="form-control"
onfocus="this.select()"
/>
{# BOT TOKEN #}
{% elseif item['type'] == 'bot_token' %}
<div class="input-group">
<span class="input-group-btn">
<button id="{{ settingKey }}-btn" class="btn btn-primary" type="button" onclick="validateBotToken()">
<i class="fa fa-refresh"></i> Проверить Bot Token
</button>
</span>
<input type="text"
name="{{ settingKey }}"
value="{{ attribute(_context, settingKey) }}"
placeholder="{{ item['placeholder'] }}"
id="{{ settingKey }}"
class="form-control"
onfocusout="validateBotToken()"
/>
</div>
<div id="{{ settingKey }}-result-label"></div>
<button class="btn btn-link btn-xs" type="button" data-toggle="collapse" data-target="#{{ settingKey }}-collapse" aria-expanded="false" aria-controls="collapseExample">
Инструкция как создать Bot Token
</button>
<div class="collapse" id="{{ settingKey }}-collapse">
<div class="well">
<p>Подробная инструкция доступна в <a href="https://nikitakiselev.github.io/telecart-docs/#telegram" target="_blank">документации <i class="fa fa-external-link"></i></a>.</p>
</div>
</div>
<script>
function validateBotToken() {
const $input = $('#{{ settingKey }}');
const $resultLabel = $('#{{ settingKey }}-result-label');
const botToken = $input.val();
const url = '/admin/index.php?route=extension/module/tgshop/handle&api_action=configureBotToken&user_token={{ user_token }}';
if (botToken.trim().length === 0) {
$resultLabel
.text(`❌ Введите Bot Token!`)
.css('color', 'red');
return;
}
$input.attr('readonly', true);
$resultLabel.text('Проверяю...');
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ botToken }),
})
.then(async (res) => {
const response = await res.json().catch(() => null);
if (res.status === 422) {
console.error(res, response);
$resultLabel
.text(`❌ Ошибка: ${response.error}`)
.css('color', 'red');
return;
}
if (!res.ok) {
throw new Error(`Ошибка ${response.error || res.statusText}`);
}
if (! response.id) {
throw new Error(`bot token is not found in server response.`);
}
$resultLabel
.text(`✅ Бот: @${response.username} (id: ${response.id}) webhook: ${response.webhook_url}`)
.css('color', 'green');
})
.catch(err => {
console.error(err);
$resultLabel
.text(`❌ Ошибка проверки BotToken.`)
.css('color', 'red');
})
.finally(() => $input.attr('readonly', false))
}
</script>
{% else %}
Unsupported {{ item|json_encode }}
{% endif %}
{% if attribute(_context, 'error_' ~ settingKey) %}
<div class="text-danger">{{ attribute(_context, 'error_' ~ settingKey) }}</div>
{% endif %}
{% if item['help'] %}
<p class="help-block">{{ item['help'] }}</p>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endfor %}
<div id="banners" class="tab-pane">
<script>
window.TeleCart = {
user_token: '{{ user_token }}',
mainpage_slider: '{{ mainpage_slider }}',
};
</script>
<div id="app">App Loading...</div>
</div>
</div>
</form>
<script>
window.TeleCart = {
user_token: '{{ user_token }}',
mainpage_slider: '{{ mainpage_slider }}',
themes: '{{ themes | json_encode }}',
order_statuses: '{{ order_statuses | json_encode }}',
};
</script>
<div id="app">App Loading...</div>
</div>
</div>
</div>
@@ -467,7 +42,7 @@
<script>
const $element = $('#thumb-image-module_tgshop_app_icon');
$('#button-clear').on('click', function() {
$('#button-clear').on('click', function () {
$element.find('img').attr('src', $element.find('img').attr('data-placeholder'));
$element.parent().find('input').val('');
$element.popover('destroy');

View File

@@ -1,60 +0,0 @@
{{ header }}{{ column_left }}
<div id="content">
<div class="page-header">
<div class="container-fluid">
<div class="pull-right">
<a href="{{ cancel }}" data-toggle="tooltip" title="{{ button_cancel }}" class="btn btn-default"><i
class="fa fa-reply"></i></a></div>
<h1>{{ heading_title }}</h1>
<ul class="breadcrumb">
{% for breadcrumb in breadcrumbs %}
<li><a href="{{ breadcrumb.href }}">{{ breadcrumb.text }}</a></li>
{% endfor %}
</ul>
</div>
</div>
<div class="container-fluid">
{% if error_warning %}
<div class="alert alert-danger alert-dismissible"><i
class="fa fa-exclamation-circle"></i> {{ error_warning }}
<button type="button" class="close" data-dismiss="alert">&times;</button>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title"><i class="fa fa-pencil"></i> Инициализация модуля</h3>
</div>
<div class="panel-body">
<div class="col-md-push-3 col-md-6 text-center">
<div style="font-size: 16px;">
<h2 style="margin-top: 50px; margin-bottom: 30px;">🛠 Добро пожаловать в модуль Telegram-магазина</h2>
<p>Этот модуль разработан с вниманием к деталям и заботой о стабильной работе вашего магазина в Telegram. Я старался сделать его максимально простым, понятным и гибким.</p>
<p>Если у вас возникнут вопросы, пожелания или нужны доработки — вы всегда можете обратиться:</p>
<ul style="list-style: none">
<li>📬 Email: <a href="mailto:kiselev2008@gmail.com">kiselev2008@gmail.com</a></li>
<li>💬 Telegram-группа: <a href="https://t.me/ocstore3" target="_blank">https://t.me/ocstore3 <i class="fa fa-external-link"></i></a></li>
</ul>
<p>Заходите в Telegram-группу, там я анонсирую свежие версии своих модулей.</p>
<div class="alert alert-info">
<p>⚠️ Перед началом работы требуется инициализация модуля.</p>
<p>Она создаст дефолтные настройки и подготовит систему к использованию.</p>
<p>Нажмите кнопку ниже, чтобы выполнить первичную настройку. Всё выполнится автоматически.</p>
</div>
<form action="{{ action }}" method="post" enctype="multipart/form-data" class="form-horizontal">
<button type="submit"
data-toggle="tooltip"
title="Нажмите чтобы выполнить начальную инициализацию модуля"
class="btn btn-primary"
>
Инициализация
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
{{ footer }}

View File

@@ -66,6 +66,7 @@ class ControllerExtensionTgshopHandle extends Controller
'featured_categories' => (array) $this->config->get('module_tgshop_featured_categories'),
'store_enabled' => filter_var($this->config->get('module_tgshop_enable_store'), FILTER_VALIDATE_BOOLEAN),
'base_url' => HTTPS_SERVER,
'ya_metrika_counter' => trim($this->config->get('module_tgshop_yandex_metrika')),
'ya_metrika_enabled' => ! empty(trim($this->config->get('module_tgshop_yandex_metrika'))),
'telegram' => [
'bot_token' => $this->config->get('module_tgshop_bot_token'),
@@ -141,7 +142,7 @@ class ControllerExtensionTgshopHandle extends Controller
return '';
}
private function safeJsonDecode(string $input, $default = null)
private function safeJsonDecode(?string $input = null, $default = null)
{
try {
return json_decode($input, true, 512, JSON_THROW_ON_ERROR);

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Bastion;
use App\ServiceProviders\AppServiceProvider;
use App\ServiceProviders\SettingsServiceProvider;
use Openguru\OpenCartFramework\Application;
use Openguru\OpenCartFramework\Cache\CacheServiceProvider;
use Openguru\OpenCartFramework\QueryBuilder\QueryBuilderServiceProvider;
@@ -14,14 +15,17 @@ use Openguru\OpenCartFramework\Telegram\TelegramServiceProvider;
class ApplicationFactory
{
public static function create(array $config): Application
public static function create(array $settings): Application
{
$defaultConfig = require __DIR__ . '/config.php';
$routes = require __DIR__ . '/routes.php';
return (new Application(Arr::mergeArraysRecursively($defaultConfig, $config)))
->withRoutes(fn () => $routes)
$merged = Arr::mergeArraysRecursively($defaultConfig, $settings);
return (new Application($merged))
->withRoutes(fn() => $routes)
->withServiceProviders([
SettingsServiceProvider::class,
QueryBuilderServiceProvider::class,
RouteServiceProvider::class,
AppServiceProvider::class,

View File

@@ -0,0 +1,64 @@
<?php
namespace Bastion\Handlers;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
use Openguru\OpenCartFramework\Support\Str;
class AutocompleteHandler
{
private OcRegistryDecorator $registry;
public function __construct(OcRegistryDecorator $registry)
{
$this->registry = $registry;
}
public function getProductsById(Request $request): JsonResponse
{
$productIds = $request->json('product_ids', []);
$products = [];
if ($productIds) {
$products = array_map(function ($productId) {
$item = [
'id' => (int) $productId,
];
$product = $this->registry->model_catalog_product->getProduct($productId);
$item['name'] = $product ? Str::htmlEntityEncode($product['name']) : 'No name';
return $item;
}, $productIds);
}
return new JsonResponse([
'data' => $products,
]);
}
public function getCategoriesById(Request $request): JsonResponse
{
$ids = $request->json('category_ids', []);
$items = [];
if ($ids) {
$items = array_map(function ($id) {
$item = [
'id' => (int) $id,
];
$entity = $this->registry->model_catalog_category->getCategory($id);
$item['name'] = $entity ? Str::htmlEntityEncode($entity['name']) : 'No name';
return $item;
}, $ids);
}
return new JsonResponse([
'data' => $items,
]);
}
}

View File

@@ -4,24 +4,35 @@ namespace Bastion\Handlers;
use Bastion\Exceptions\BotTokenConfiguratorException;
use Bastion\Services\BotTokenConfigurator;
use Bastion\Services\SettingsService;
use Exception;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\Http\Response;
use Openguru\OpenCartFramework\Support\Arr;
class SettingsHandler
{
private BotTokenConfigurator $botTokenConfigurator;
private Settings $settings;
private SettingsService $settingsUpdateService;
public function __construct(BotTokenConfigurator $botTokenConfigurator)
{
public function __construct(
BotTokenConfigurator $botTokenConfigurator,
Settings $settings,
SettingsService $settingsUpdateService
) {
$this->botTokenConfigurator = $botTokenConfigurator;
$this->settings = $settings;
$this->settingsUpdateService = $settingsUpdateService;
}
public function configureBotToken(Request $request): JsonResponse
{
try {
$data = $this->botTokenConfigurator->configure(trim($request->json('botToken', '')));
return new JsonResponse($data);
} catch (BotTokenConfiguratorException $e) {
return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
@@ -29,4 +40,34 @@ class SettingsHandler
return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function getSettingsForm(): JsonResponse
{
$data = Arr::getWithKeys($this->settings->getAll(), [
'app',
'telegram',
'metrics',
'store',
'orders',
'texts',
'sliders',
]);
return new JsonResponse(compact('data'));
}
public function saveSettingsForm(Request $request): JsonResponse
{
$this->validate($request->json());
$this->settingsUpdateService->update(
$request->json(),
);
return new JsonResponse([], Response::HTTP_ACCEPTED);
}
private function validate(array $input): void
{
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Bastion\Handlers;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\QueryBuilder\Builder;
use Openguru\OpenCartFramework\QueryBuilder\JoinClause;
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
use Openguru\OpenCartFramework\QueryBuilder\Table;
class StatsHandler
{
private Builder $builder;
public function __construct(Builder $builder)
{
$this->builder = $builder;
}
public function getDashboardStats(): JsonResponse
{
$ordersTotalAmount = $this->builder->newQuery()
->select([
new RawExpression('COUNT(DISTINCT orders.order_id) AS orders_count'),
new RawExpression('SUM(orders.total) AS orders_total_amount'),
new RawExpression('COUNT(DISTINCT order_product.product_id) AS order_products_count'),
])
->from(db_table('order'), 'orders')
->join(new Table(db_table('order_history'), 'order_history'), function (JoinClause $join) {
$join->on('orders.order_id', '=', 'order_history.order_id')
->where('order_history.comment', '=', 'Заказ оформлен через Telegram Mini App');
})
->join(new Table(db_table('order_product'), 'order_product'), function (JoinClause $join) {
$join->on('orders.order_id', '=', 'order_product.order_id');
})
->firstOrNull();
if ($ordersTotalAmount) {
$data = [
'orders_count' => (int) $ordersTotalAmount['orders_count'],
'orders_total_amount' => (int) $ordersTotalAmount['orders_total_amount'],
'order_products_count' => (int) $ordersTotalAmount['order_products_count'],
];
}
return new JsonResponse(compact('data'));
}
}

View File

@@ -2,18 +2,29 @@
namespace Bastion\Handlers;
use App\Services\SettingsService;
use Exception;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use Openguru\OpenCartFramework\Cache\CacheInterface;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\Http\Response;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\Telegram\Exceptions\TelegramClientException;
use Openguru\OpenCartFramework\Telegram\TelegramService;
class TelegramHandler
{
private CacheInterface $cache;
private TelegramService $telegramService;
private SettingsService $settings;
public function __construct(CacheInterface $cache)
public function __construct(CacheInterface $cache, TelegramService $telegramService, SettingsService $settings)
{
$this->cache = $cache;
$this->telegramService = $telegramService;
$this->settings = $settings;
}
public function getChatId(): JsonResponse
@@ -46,4 +57,79 @@ class TelegramHandler
],
]);
}
public function testTgMessage(Request $request): JsonResponse
{
$template = $request->json('template', 'Нет шаблона');
$token = $request->json('token');
$chatId = $request->json('chat_id');
if (! $token) {
return new JsonResponse([
'message' => 'Не задан Telegram BotToken',
]);
}
if (! $chatId) {
return new JsonResponse([
'message' => 'Не задан ChatID.',
]);
}
$variables = [
'{store_name}' => $this->settings->config()->getApp()->getAppName(),
'{order_id}' => 777,
'{customer}' => 'Иван Васильевич',
'{email}' => 'telegram@opencart.com',
'{phone}' => '+79999999999',
'{comment}' => 'Это тестовый заказ',
'{address}' => 'г. Москва',
'{total}' => 100000,
'{ip}' => '127.0.0.1',
'{created_at}' => date('Y-m-d H:i:s'),
];
$message = $this->telegramService->prepareMessage($template, $variables);
try {
$this->telegramService
->setBotToken($token)
->sendMessage($chatId, $message);
return new JsonResponse([
'message' => 'Сообщение отправлено. Проверьте Telegram.',
]);
} catch (ClientException $exception) {
$json = json_decode($exception->getResponse()->getBody(), true);
return new JsonResponse([
'message' => $json['description'],
]);
} catch (Exception $e) {
return new JsonResponse([
'message' => $e->getMessage(),
]);
}
}
/**
* @throws GuzzleException
* @throws TelegramClientException
* @throws \JsonException
*/
public function tgGetMe(): JsonResponse
{
if (! $this->settings->config()->getTelegram()->getBotToken()) {
return new JsonResponse(['data' => null]);
}
$data = $this->cache->get('tg_me_info');
if (! $data) {
$data = $this->telegramService->exec('getMe');
$this->cache->set('tg_me_info', $data, 60);
}
return new JsonResponse(compact('data'));
}
}

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace Bastion\Services;
use App\Services\SettingsService;
use Bastion\Exceptions\BotTokenConfiguratorException;
use Exception;
use GuzzleHttp\Exception\GuzzleException;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Logger\LoggerInterface;
use Openguru\OpenCartFramework\Router\Router;
use Openguru\OpenCartFramework\Support\Arr;
@@ -17,13 +17,13 @@ use Openguru\OpenCartFramework\Telegram\TelegramService;
class BotTokenConfigurator
{
private TelegramService $telegramService;
private Settings $settings;
private SettingsService $settings;
private Router $router;
private LoggerInterface $logger;
public function __construct(
TelegramService $telegramService,
Settings $settings,
SettingsService $settings,
Router $router,
LoggerInterface $logger
) {
@@ -72,15 +72,18 @@ class BotTokenConfigurator
}
}
/**
* @throws BotTokenConfiguratorException
*/
private function getWebhookUrl(): string
{
$publicUrl = rtrim($this->settings->get('public_url'), '/');
$publicUrl = rtrim($this->settings->config()->getApp()->getShopBaseUrl(), '/');
if (! $publicUrl) {
throw new BotTokenConfiguratorException('Public URL is not set in configuration.');
}
$webhook = $this->router->url($this->settings->get('tg_webhook_handler', 'webhook'));
$webhook = $this->router->url('webhook');
return $publicUrl . $webhook;
}

View File

@@ -0,0 +1,22 @@
<?php
namespace Bastion\Services;
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
class SettingsService
{
private OcRegistryDecorator $registry;
public function __construct(OcRegistryDecorator $registry)
{
$this->registry = $registry;
}
public function update(array $data): void
{
$this->registry->model_setting_setting->editSetting('module_telecart', [
'module_telecart_settings' => $data,
]);
}
}

View File

@@ -1,20 +1,84 @@
<?php
return [
'config_timezone' => 'UTC',
'lang' => 'en-gb',
'language_id' => 1,
'auth_user_id' => 0,
'base_url' => '/',
'db' => [
'host' => 'localhost',
'database' => 'not_set',
'username' => 'not_set',
'password' => 'not_set',
'app' => [
'app_enabled' => true,
'app_name' => 'Telecart',
'app_icon' => null,
"theme_light" => "light",
"theme_dark" => "dark",
"app_debug" => false
],
'logs' => [
'path' => 'not_set',
'telegram' => [
"bot_token" => "",
"chat_id" => null,
"owner_notification_template" => <<<TEXT
*Новый заказ \#{order_id}* в магазине *{store_name}*
*Покупатель:* {customer}
*Email:* {email}
*Телефон:* {phone}
*IP:* {ip}
*Адрес доставки:*
{address}
*Комментарий:*
{comment}
*Сумма заказа:* {total}
*Дата оформления:* {created_at}
TEXT,
"customer_notification_template" => <<<TEXT
Спасибо за Ваш заказ в магазине *{store_name}*
*Номер заказа* \#{order_id}
*Сумма заказа:* {total}
*Дата оформления:* {created_at}
Мы свяжемся с вами при необходимости\.
Хорошего дня\!
TEXT,
"mini_app_url" => "",
],
"metrics" => [
"yandex_metrika_enabled" => false,
"yandex_metrika_counter" => "",
],
'store' => [
'enable_store' => true,
'mainpage_products' => 'most_viewed',
'featured_products' => [],
'mainpage_categories' => 'latest10',
'featured_categories' => [],
'feature_coupons' => true,
'feature_vouchers' => true,
],
'texts' => [
'text_no_more_products' => 'Это всё по текущему запросу. Попробуйте уточнить фильтры или поиск.',
'text_empty_cart' => 'Ваша корзина пуста.',
'text_order_created_success' => 'Ваш заказ успешно оформлен и будет обработан в ближайшее время.'
],
'orders' => [
'order_default_status_id' => 1,
],
'sliders' => [
'mainpage_slider' => [
'is_enabled' => false,
'effect' => 'slide',
'pagination' => true,
'scrollbar' => false,
'free_mode' => false,
'space_between' => 30,
'autoplay' => false,
'loop' => false,
'slides' => [],
],
],
];

View File

@@ -1,9 +1,18 @@
<?php
use Bastion\Handlers\AutocompleteHandler;
use Bastion\Handlers\SettingsHandler;
use Bastion\Handlers\StatsHandler;
use Bastion\Handlers\TelegramHandler;
return [
'configureBotToken' => [SettingsHandler::class, 'configureBotToken'],
'getChatId' => [TelegramHandler::class, 'getChatId'],
'getSettingsForm' => [SettingsHandler::class, 'getSettingsForm'],
'saveSettingsForm' => [SettingsHandler::class, 'saveSettingsForm'],
'testTgMessage' => [TelegramHandler::class, 'testTgMessage'],
'getProductsById' => [AutocompleteHandler::class, 'getProductsById'],
'getCategoriesById' => [AutocompleteHandler::class, 'getCategoriesById'],
'getDashboardStats' => [StatsHandler::class, 'getDashboardStats'],
'tgGetMe' => [TelegramHandler::class, 'tgGetMe'],
];

View File

@@ -56,7 +56,7 @@ class Application extends Container
return $container;
});
$this->singleton(LoggerInterface::class, fn () => $this->logger);
$this->singleton(LoggerInterface::class, fn() => $this->logger);
$this->singleton(Settings::class, function (Container $container) {
return new Settings($container->getConfigValue());
@@ -106,11 +106,11 @@ class Application extends Container
$this->profiler->addCheckpoint('Handle Middlewares.');
$next = fn ($req) => $this->call($controller, $method);
$next = fn($req) => $this->call($controller, $method);
foreach (array_reverse($this->middlewareStack) as $class) {
$instance = $this->get($class);
$next = static fn ($req) => $instance->handle($req, $next);
$next = static fn($req) => $instance->handle($req, $next);
}
$response = $next($request);

View File

@@ -4,47 +4,32 @@ namespace Openguru\OpenCartFramework\Config;
use Openguru\OpenCartFramework\Support\Arr;
class Settings
class Settings implements SettingsInterface
{
private $settings;
private array $config;
public function __construct(array $initialSettings = [])
public function __construct(array $config)
{
$this->settings = $initialSettings;
$this->config = $config;
}
public function get(string $key, $default = null)
{
return Arr::get($this->settings, $key, $default);
}
public function set(string $key, $value): void
{
Arr::set($this->settings, $key, $value);
return Arr::get($this->getAll(), $key, $default);
}
public function has(string $key): bool
{
return Arr::get($this->settings, $key) !== null;
}
public function remove(string $key): void
{
Arr::unset($this->settings, $key);
return Arr::get($this->getAll(), $key) !== null;
}
public function getAll(): array
{
return $this->settings;
}
public function setAll(array $settings): void
{
$this->settings = $settings;
return $this->config;
}
public function getHash(): string
{
return md5(serialize($this->settings));
return md5(serialize($this->getAll()));
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Openguru\OpenCartFramework\Config;
interface SettingsInterface
{
public function get(string $key, $default = null);
public function has(string $key): bool;
public function getAll(): array;
public function getHash(): string;
}

View File

@@ -14,7 +14,7 @@ use ReflectionMethod;
use ReflectionNamedType;
use RuntimeException;
if (!defined('BP_BASE_PATH')) {
if (! defined('BP_BASE_PATH')) {
$phar = Phar::running(false);
define('BP_BASE_PATH', $phar ? "phar://$phar" : dirname(__DIR__) . '/..');
}
@@ -24,7 +24,7 @@ class Container implements ContainerInterface
private array $factories = [];
private array $instances = [];
private array $config;
private $taggedAbstracts = [];
private array $taggedAbstracts = [];
public function __construct(array $config)
{
@@ -70,7 +70,7 @@ class Container implements ContainerInterface
try {
if ($this->has($id)) {
if ($this->factories[$id]['singleton']) {
if (!array_key_exists($id, $this->instances)) {
if (! array_key_exists($id, $this->instances)) {
$this->instances[$id] = $this->factories[$id]['concrete']($this);
}
@@ -120,11 +120,11 @@ class Container implements ContainerInterface
*/
public function call(string $abstract, string $method): JsonResponse
{
if (!class_exists($abstract)) {
if (! class_exists($abstract)) {
throw new ContainerDependencyResolutionException('Could not resolve the concrete: ' . $abstract);
}
if (!method_exists($abstract, $method)) {
if (! method_exists($abstract, $method)) {
throw new ContainerDependencyResolutionException('Method not found: ' . $abstract . '@' . $method);
}

View File

@@ -4,6 +4,7 @@ namespace Openguru\OpenCartFramework;
use ErrorException;
use Openguru\OpenCartFramework\Contracts\ExceptionHandlerInterface;
use Openguru\OpenCartFramework\Exceptions\ActionNotFoundException;
use Openguru\OpenCartFramework\Exceptions\NonLoggableExceptionInterface;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Response;
@@ -58,6 +59,13 @@ class ErrorHandler
$this->logger->logException($exception);
}
if ($exception instanceof ActionNotFoundException) {
(new JsonResponse([
'message' => sprintf('Action %s is not found.', $exception->getAction()),
], Response::HTTP_NOT_FOUND))->send();
exit(1);
}
if (PHP_SAPI === 'cli') {
echo $exception->getMessage() . PHP_EOL;
} else {

View File

@@ -6,4 +6,17 @@ use Exception;
class ActionNotFoundException extends Exception implements NonLoggableExceptionInterface
{
private string $action;
public function __construct(string $action, $message = "", $code = 0, Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
$this->action = $action;
}
public function getAction(): string
{
return $this->action;
}
}

View File

@@ -14,11 +14,11 @@ class QueryBuilderServiceProvider extends ServiceProvider
public function register(): void
{
$this->container->bind(ConnectionInterface::class, function (Container $container) {
$host = $container->getConfigValue('db.host');
$username = $container->getConfigValue('db.username');
$password = $container->getConfigValue('db.password');
$port = $container->getConfigValue('db.port');
$dbName = $container->getConfigValue('db.database');
$host = $container->getConfigValue('database.host');
$username = $container->getConfigValue('database.username');
$password = $container->getConfigValue('database.password');
$port = (int) $container->getConfigValue('database.port');
$dbName = $container->getConfigValue('database.database');
$dsn = "mysql:host=$host;port=$port;dbname=$dbName";
@@ -34,4 +34,4 @@ class QueryBuilderServiceProvider extends ServiceProvider
);
});
}
}
}

View File

@@ -2,6 +2,7 @@
namespace Openguru\OpenCartFramework\Router;
use InvalidArgumentException;
use Openguru\OpenCartFramework\Exceptions\ActionNotFoundException;
class Router
@@ -26,14 +27,14 @@ class Router
public function resolve($action): array
{
if (! $action) {
throw new ActionNotFoundException('No action provided');
throw new InvalidArgumentException('No action provided');
}
if (isset($this->routes[$action])) {
return $this->routes[$action];
}
throw new ActionNotFoundException('Action "' . $action . '" not found.');
throw new ActionNotFoundException($action, 'Action "' . $action . '" not found.');
}
public function url(string $action, array $query = []): string

View File

@@ -18,11 +18,12 @@ class Arr
{
$result = [];
foreach ($array as $item) {
if (!array_key_exists($keyField, $item)) {
if (! array_key_exists($keyField, $item)) {
throw new InvalidArgumentException("Key field '{$keyField}' is missing in one of the items.");
}
$result[$item[$keyField]] = $item;
}
return $result;
}
@@ -45,9 +46,31 @@ class Arr
return $result;
}
public static function has(array $items, string $key): bool
{
if (empty($items)) {
return false;
}
if (array_key_exists($key, $items)) {
return true;
}
$segments = explode('.', $key);
foreach ($segments as $segment) {
if (! is_array($items) || ! array_key_exists($segment, $items)) {
return false;
}
$items = $items[$segment];
}
return true;
}
public static function get(array $items, string $key, $default = null)
{
if (!$items) {
if (! $items) {
return $default;
}
@@ -57,7 +80,7 @@ class Arr
$segments = explode('.', $key);
foreach ($segments as $segment) {
if (!is_array($items) || !array_key_exists($segment, $items)) {
if (! is_array($items) || ! array_key_exists($segment, $items)) {
return $default;
}
@@ -71,7 +94,7 @@ class Arr
{
$keys = explode('.', $key);
foreach ($keys as $k) {
if (!isset($array[$k]) || !is_array($array[$k])) {
if (! isset($array[$k]) || ! is_array($array[$k])) {
$array[$k] = [];
}
$array = &$array[$k];
@@ -84,7 +107,7 @@ class Arr
$keys = explode('.', $key);
while (count($keys) > 1) {
$k = array_shift($keys);
if (!isset($array[$k]) || !is_array($array[$k])) {
if (! isset($array[$k]) || ! is_array($array[$k])) {
return;
}
$array = &$array[$k];
@@ -119,13 +142,73 @@ class Arr
public static function mergeArraysRecursively(array $base, array $override): array
{
$result = $base;
foreach ($override as $key => $value) {
if (isset($base[$key]) && is_array($base[$key]) && is_array($value)) {
$base[$key] = static::mergeArraysRecursively($base[$key], $value);
if (isset($result[$key]) && is_array($result[$key]) && is_array($value)) {
$result[$key] = static::mergeArraysRecursively($result[$key], $value);
} else {
$base[$key] = $value;
$result[$key] = $value;
}
}
return $base;
return $result;
}
/**
* Объединяет массивы рекурсивно, где ключи override могут быть в dot notation.
* Преобразует dot notation ключи в вложенную структуру перед объединением.
*
* @param array $base Базовый массив
* @param array $override Массив с ключами в dot notation (например, ['app.logs.path' => '/var/log'])
* @return array Объединенный массив
*
* @example
* $base = ['app' => ['name' => 'MyApp']];
* $override = ['app.logs.path' => '/var/log'];
* // Результат: ['app' => ['name' => 'MyApp', 'logs' => ['path' => '/var/log']]]
*/
public static function mergeArraysRecursivelyWithDotNotation(array $base, array $override): array
{
$overrideNested = [];
foreach ($override as $key => $value) {
if (strpos($key, '.') !== false) {
// Используем существующий метод set для создания вложенной структуры
static::set($overrideNested, $key, $value);
} else {
// Обычный ключ без dot notation
$overrideNested[$key] = $value;
}
}
// Объединяем с базовым массивом
return static::mergeArraysRecursively($base, $overrideNested);
}
/**
* Возвращает массив только с указанными ключами в dot notation
* Сохраняет структуру вложенности из исходного массива
*
* @param array $array Исходный массив
* @param array $keys Массив ключей в dot notation (например, ['app.name', 'app.logs.path', 'telegram.bot_token'])
* @return array Отфильтрованный массив с сохранением структуры
*
* @example
* $array = ['app' => ['name' => 'MyApp', 'logs' => ['path' => '/var/log']], 'telegram' => ['bot_token' => 'token']];
* Arr::getWithKeys($array, ['app.name', 'app.logs.path', 'telegram.bot_token'])
* // Вернет: ['app' => ['name' => 'MyApp', 'logs' => ['path' => '/var/log']], 'telegram' => ['bot_token' => 'token']]
*/
public static function getWithKeys(array $array, array $keys): array
{
$filtered = [];
foreach ($keys as $key) {
if (static::has($array, $key)) {
$value = static::get($array, $key);
static::set($filtered, $key, $value);
}
}
return $filtered;
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace Openguru\OpenCartFramework\Support;
class Str
{
/**
* Determine if a given string starts with a given substring.
*
* @param string $haystack The string to search in.
* @param string|array $needles The substring(s) to search for.
* @return bool True if the string starts with any of the given substrings, false otherwise.
*/
public static function startsWith(string $haystack, $needles): bool
{
foreach ((array) $needles as $needle) {
if ($needle === '') {
if ($haystack === '') {
return true;
}
continue;
}
if (strncmp($haystack, $needle, strlen($needle)) === 0) {
return true;
}
}
return false;
}
public static function htmlEntityEncode(string $string): string
{
return html_entity_decode($string, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
}

View File

@@ -7,7 +7,7 @@ use Openguru\OpenCartFramework\Support\Utils;
if (! function_exists('table')) {
function db_table(string $name): string
{
$prefix = Application::getInstance()->getConfigValue('db.prefix');
$prefix = Application::getInstance()->getConfigValue('database.prefix');
return $prefix . $name;
}

View File

@@ -2,7 +2,7 @@
namespace Openguru\OpenCartFramework\Telegram;
use Openguru\OpenCartFramework\Config\Settings;
use App\Services\SettingsService;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\Logger\LoggerInterface;
use Openguru\OpenCartFramework\Telegram\Exceptions\TelegramInvalidSignatureException;
@@ -10,10 +10,10 @@ use Openguru\OpenCartFramework\Telegram\Exceptions\TelegramInvalidSignatureExcep
class SignatureValidator
{
private ?string $botToken;
private Settings $settings;
private SettingsService $settings;
private LoggerInterface $logger;
public function __construct(Settings $settings, LoggerInterface $logger, ?string $botToken = null)
public function __construct(SettingsService $settings, LoggerInterface $logger, ?string $botToken = null)
{
$this->botToken = $botToken;
$this->settings = $settings;
@@ -22,8 +22,9 @@ class SignatureValidator
public function validate(Request $request): void
{
if ($this->settings->get('app_debug')) {
if ($this->settings->config()->getApp()->isAppDebug()) {
$this->logger->warning('Dev Mode is enabled. Ignoring Signature Validation.');
return;
}
@@ -39,7 +40,7 @@ class SignatureValidator
$data = $this->parseInitDataStringToArray($initDataString);
if (!isset($data['hash'])) {
if (! isset($data['hash'])) {
throw new TelegramInvalidSignatureException('Missing hash in init data');
}

View File

@@ -2,8 +2,8 @@
namespace Openguru\OpenCartFramework\Telegram;
use App\Services\SettingsService;
use Openguru\OpenCartFramework\Application;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Container\ServiceProvider;
use Openguru\OpenCartFramework\Logger\LoggerInterface;
@@ -13,12 +13,13 @@ class TelegramServiceProvider extends ServiceProvider
{
$this->container->singleton(TelegramService::class, function (Application $app) {
$botToken = $app->getConfigValue('telegram.bot_token');
return new TelegramService($botToken);
});
$this->container->singleton(SignatureValidator::class, function (Application $app) {
return new SignatureValidator(
$app->get(Settings::class),
$app->get(SettingsService::class),
$app->get(LoggerInterface::class),
$app->getConfigValue('telegram.bot_token'),
);

View File

@@ -10,7 +10,7 @@ class TranslatorServiceProvider extends ServiceProvider
public function register(): void
{
$this->container->singleton(TranslatorInterface::class, function (Container $container) {
$language = $container->getConfigValue('lang');
$language = $container->getConfigValue('lang', 1);
$translator = new Translator($language);
$translator->loadTranslationsFromFolder(resources_path('/translations'));
return $translator;

View File

@@ -3,6 +3,7 @@
namespace App;
use App\ServiceProviders\AppServiceProvider;
use App\ServiceProviders\SettingsServiceProvider;
use Openguru\OpenCartFramework\Application;
use Openguru\OpenCartFramework\Cache\CacheServiceProvider;
use Openguru\OpenCartFramework\QueryBuilder\QueryBuilderServiceProvider;
@@ -20,8 +21,9 @@ class ApplicationFactory
$routes = require __DIR__ . '/routes.php';
return (new Application(Arr::mergeArraysRecursively($defaultConfig, $config)))
->withRoutes(fn () => $routes)
->withRoutes(fn() => $routes)
->withServiceProviders([
SettingsServiceProvider::class,
QueryBuilderServiceProvider::class,
CacheServiceProvider::class,
RouteServiceProvider::class,

View File

@@ -0,0 +1,89 @@
<?php
namespace App\DTO\Settings;
final class AppDTO
{
private bool $appEnabled;
private string $appName;
private ?string $appIcon;
private string $themeLight;
private string $themeDark;
private bool $appDebug;
private int $languageId;
private string $shopBaseUrl;
public function __construct(
bool $appEnabled,
string $appName,
?string $appIcon,
string $themeLight,
string $themeDark,
bool $appDebug,
int $languageId,
string $shopBaseUrl
) {
$this->appEnabled = $appEnabled;
$this->appName = $appName;
$this->appIcon = $appIcon;
$this->themeLight = $themeLight;
$this->themeDark = $themeDark;
$this->appDebug = $appDebug;
$this->languageId = $languageId;
$this->shopBaseUrl = $shopBaseUrl;
}
public function isAppEnabled(): bool
{
return $this->appEnabled;
}
public function getAppName(): string
{
return $this->appName;
}
public function getAppIcon(): ?string
{
return $this->appIcon;
}
public function getThemeLight(): string
{
return $this->themeLight;
}
public function getThemeDark(): string
{
return $this->themeDark;
}
public function isAppDebug(): bool
{
return $this->appDebug;
}
public function getLanguageId(): int
{
return $this->languageId;
}
public function getShopBaseUrl(): string
{
return $this->shopBaseUrl;
}
public function toArray(): array
{
return [
'app_enabled' => $this->isAppEnabled(),
'app_name' => $this->getAppName(),
'app_icon' => $this->getAppIcon(),
'theme_light' => $this->getThemeLight(),
'theme_dark' => $this->getThemeDark(),
'app_debug' => $this->isAppDebug(),
'language_id' => $this->getLanguageId(),
'shop_base_url' => $this->getShopBaseUrl(),
];
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\DTO\Settings;
final class ConfigDTO
{
private AppDTO $app;
private TelegramDTO $telegram;
private MetricsDTO $metrics;
private StoreDTO $store;
private OrdersDTO $orders;
private TextsDTO $texts;
private SlidersDTO $sliders;
private DatabaseDTO $database;
private LogsDTO $logs;
public function __construct(
AppDTO $app,
TelegramDTO $telegram,
MetricsDTO $metrics,
StoreDTO $store,
OrdersDTO $orders,
TextsDTO $texts,
SlidersDTO $sliders,
DatabaseDTO $database,
LogsDTO $logs
) {
$this->app = $app;
$this->telegram = $telegram;
$this->metrics = $metrics;
$this->store = $store;
$this->orders = $orders;
$this->texts = $texts;
$this->sliders = $sliders;
$this->database = $database;
$this->logs = $logs;
}
public function getApp(): AppDTO
{
return $this->app;
}
public function getTelegram(): TelegramDTO
{
return $this->telegram;
}
public function getMetrics(): MetricsDTO
{
return $this->metrics;
}
public function getStore(): StoreDTO
{
return $this->store;
}
public function getOrders(): OrdersDTO
{
return $this->orders;
}
public function getTexts(): TextsDTO
{
return $this->texts;
}
public function getSliders(): SlidersDTO
{
return $this->sliders;
}
public function getDatabase(): DatabaseDTO
{
return $this->database;
}
public function getLogs(): LogsDTO
{
return $this->logs;
}
public function toArray(): array
{
return [
'app' => $this->app->toArray(),
'database' => $this->database->toArray(),
'logs' => $this->logs->toArray(),
'metrics' => $this->metrics->toArray(),
'orders' => $this->orders->toArray(),
'sliders' => $this->sliders->toArray(),
'store' => $this->store->toArray(),
'telegram' => $this->telegram->toArray(),
'texts' => $this->texts->toArray(),
];
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\DTO\Settings;
final class DatabaseDTO
{
private string $host;
private string $database;
private string $username;
private string $password;
private string $prefix;
private int $port;
public function __construct(
string $host,
string $database,
string $username,
string $password,
string $prefix,
int $port
) {
$this->host = $host;
$this->database = $database;
$this->username = $username;
$this->password = $password;
$this->prefix = $prefix;
$this->port = $port;
}
public function getHost(): string
{
return $this->host;
}
public function getDatabase(): string
{
return $this->database;
}
public function getUsername(): string
{
return $this->username;
}
public function getPassword(): string
{
return $this->password;
}
public function getPrefix(): string
{
return $this->prefix;
}
public function getPort(): int
{
return $this->port;
}
public function toArray(): array
{
return [
'host' => $this->host,
'database' => $this->database,
'username' => $this->username,
'password' => $this->password,
'prefix' => $this->prefix,
'port' => $this->port,
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\DTO\Settings;
final class LogsDTO
{
private string $path;
public function __construct(string $path)
{
$this->path = $path;
}
public function getPath(): string
{
return $this->path;
}
public function toArray(): array
{
return [
'path' => $this->path,
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\DTO\Settings\MainpageSlider;
final class LinkDTO
{
private string $type;
private ?LinkValueDTO $value;
public function __construct(
string $type,
?LinkValueDTO $value
) {
$this->type = $type;
$this->value = $value;
}
public function getType(): string
{
return $this->type;
}
public function getValue(): ?LinkValueDTO
{
return $this->value;
}
public function toArray(): array
{
$result = [
'type' => $this->type,
'value' => null,
];
if ($this->value !== null) {
$result['value'] = $this->value->toArray();
}
return $result;
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\DTO\Settings\MainpageSlider;
final class LinkType
{
public const NONE = 'none';
public const CATEGORY = 'category';
public const PRODUCT = 'product';
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\DTO\Settings\MainpageSlider;
final class LinkValueDTO
{
private ?int $categoryId;
private ?string $name;
private ?int $productId;
public function __construct(
?int $categoryId = null,
?string $name = null,
?int $productId = null
) {
$this->categoryId = $categoryId;
$this->name = $name;
$this->productId = $productId;
}
public function getCategoryId(): ?int
{
return $this->categoryId;
}
public function getName(): ?string
{
return $this->name;
}
public function getProductId(): ?int
{
return $this->productId;
}
public function toArray(): array
{
$result = [];
if ($this->categoryId !== null) {
$result['category_id'] = $this->categoryId;
}
if ($this->name !== null) {
$result['name'] = $this->name;
}
if ($this->productId !== null) {
$result['product_id'] = $this->productId;
}
return $result;
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace App\DTO\Settings\MainpageSlider;
final class MainpageSliderDTO
{
private bool $isEnabled;
private string $effect;
private bool $pagination;
private bool $scrollbar;
private bool $freeMode;
private int $spaceBetween;
private bool $autoplay;
private bool $loop;
/** @var SlideDTO[] */
private array $slides;
/**
* @param SlideDTO[] $slides
*/
public function __construct(
bool $isEnabled,
string $effect,
bool $pagination,
bool $scrollbar,
bool $freeMode,
int $spaceBetween,
bool $autoplay,
bool $loop,
array $slides
) {
$this->isEnabled = $isEnabled;
$this->effect = $effect;
$this->pagination = $pagination;
$this->scrollbar = $scrollbar;
$this->freeMode = $freeMode;
$this->spaceBetween = $spaceBetween;
$this->autoplay = $autoplay;
$this->loop = $loop;
$this->slides = $slides;
}
public function isEnabled(): bool
{
return $this->isEnabled;
}
public function getEffect(): string
{
return $this->effect;
}
public function isPagination(): bool
{
return $this->pagination;
}
public function isScrollbar(): bool
{
return $this->scrollbar;
}
public function isFreeMode(): bool
{
return $this->freeMode;
}
public function getSpaceBetween(): int
{
return $this->spaceBetween;
}
public function isAutoplay(): bool
{
return $this->autoplay;
}
public function isLoop(): bool
{
return $this->loop;
}
/**
* @return SlideDTO[]
*/
public function getSlides(): array
{
return $this->slides;
}
public function toArray(): array
{
$slides = [];
foreach ($this->slides as $slide) {
$slides[] = $slide->toArray();
}
return [
'is_enabled' => $this->isEnabled,
'effect' => $this->effect,
'pagination' => $this->pagination,
'scrollbar' => $this->scrollbar,
'free_mode' => $this->freeMode,
'space_between' => $this->spaceBetween,
'autoplay' => $this->autoplay,
'loop' => $this->loop,
'slides' => $slides,
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\DTO\Settings\MainpageSlider;
final class SlideDTO
{
private string $title;
private LinkDTO $link;
private string $image;
public function __construct(
string $title,
LinkDTO $link,
string $image
) {
$this->title = $title;
$this->link = $link;
$this->image = $image;
}
public function getTitle(): string
{
return $this->title;
}
public function getLink(): LinkDTO
{
return $this->link;
}
public function getImage(): string
{
return $this->image;
}
public function toArray(): array
{
return [
'title' => $this->title,
'link' => $this->link->toArray(),
'image' => $this->image,
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\DTO\Settings;
final class MetricsDTO
{
private bool $yandexMetrikaEnabled;
private string $yandexMetrikaCounter;
public function __construct(
bool $yandexMetrikaEnabled,
string $yandexMetrikaCounter
) {
$this->yandexMetrikaEnabled = $yandexMetrikaEnabled;
$this->yandexMetrikaCounter = $yandexMetrikaCounter;
}
public function isYandexMetrikaEnabled(): bool
{
return $this->yandexMetrikaEnabled;
}
public function getYandexMetrikaCounter(): string
{
return $this->yandexMetrikaCounter;
}
public function toArray(): array
{
return [
'yandex_metrika_enabled' => $this->yandexMetrikaEnabled,
'yandex_metrika_counter' => $this->yandexMetrikaCounter,
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\DTO\Settings;
final class OrdersDTO
{
private int $orderDefaultStatusId;
private int $ocCustomerGroupId;
public function __construct(int $orderDefaultStatusId, int $ocCustomerGroupId)
{
$this->orderDefaultStatusId = $orderDefaultStatusId;
$this->ocCustomerGroupId = $ocCustomerGroupId;
}
public function getOrderDefaultStatusId(): int
{
return $this->orderDefaultStatusId;
}
public function getOcCustomerGroupId(): int
{
return $this->ocCustomerGroupId;
}
public function toArray(): array
{
return [
'order_default_status_id' => $this->orderDefaultStatusId,
'oc_customer_group_id' => $this->ocCustomerGroupId,
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\DTO\Settings;
use App\DTO\Settings\MainpageSlider\MainpageSliderDTO;
final class SlidersDTO
{
private MainpageSliderDTO $mainpageSlider;
public function __construct(MainpageSliderDTO $mainpageSlider)
{
$this->mainpageSlider = $mainpageSlider;
}
public function getMainpageSlider(): MainpageSliderDTO
{
return $this->mainpageSlider;
}
public function toArray(): array
{
return [
'mainpage_slider' => $this->mainpageSlider->toArray(),
];
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\DTO\Settings;
final class StoreDTO
{
private bool $enableStore;
private string $mainpageProducts;
/** @var int[] */
private array $featuredProducts;
private string $mainpageCategories;
/** @var int[] */
private array $featuredCategories;
private bool $featureCoupons;
private bool $featureVouchers;
private string $ocDefaultCurrency;
private bool $ocConfigTax;
private int $ocStoreId;
/**
* @param int[] $featuredProducts
* @param int[] $featuredCategories
*/
public function __construct(
bool $enableStore,
string $mainpageProducts,
array $featuredProducts,
string $mainpageCategories,
array $featuredCategories,
bool $featureCoupons,
bool $featureVouchers,
string $ocDefaultCurrency,
bool $ocConfigTax,
int $ocStoreId
) {
$this->enableStore = $enableStore;
$this->mainpageProducts = $mainpageProducts;
$this->featuredProducts = $featuredProducts;
$this->mainpageCategories = $mainpageCategories;
$this->featuredCategories = $featuredCategories;
$this->featureCoupons = $featureCoupons;
$this->featureVouchers = $featureVouchers;
$this->ocDefaultCurrency = $ocDefaultCurrency;
$this->ocConfigTax = $ocConfigTax;
$this->ocStoreId = $ocStoreId;
}
public function isEnableStore(): bool
{
return $this->enableStore;
}
public function getMainpageProducts(): string
{
return $this->mainpageProducts;
}
/**
* @return int[]
*/
public function getFeaturedProducts(): array
{
return $this->featuredProducts;
}
public function getMainpageCategories(): string
{
return $this->mainpageCategories;
}
/**
* @return int[]
*/
public function getFeaturedCategories(): array
{
return $this->featuredCategories;
}
public function isFeatureCoupons(): bool
{
return $this->featureCoupons;
}
public function isFeatureVouchers(): bool
{
return $this->featureVouchers;
}
public function getOcDefaultCurrency(): string
{
return $this->ocDefaultCurrency;
}
public function isOcConfigTax(): bool
{
return $this->ocConfigTax;
}
public function getOcStoreId(): int
{
return $this->ocStoreId;
}
public function toArray(): array
{
return [
'enable_store' => $this->enableStore,
'mainpage_products' => $this->mainpageProducts,
'featured_products' => $this->featuredProducts,
'mainpage_categories' => $this->mainpageCategories,
'featured_categories' => $this->featuredCategories,
'feature_coupons' => $this->featureCoupons,
'feature_vouchers' => $this->featureVouchers,
'oc_default_currency' => $this->ocDefaultCurrency,
'oc_config_tax' => $this->ocConfigTax,
'oc_store_id' => $this->ocStoreId,
];
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\DTO\Settings;
final class TelegramDTO
{
private string $botToken;
private ?int $chatId;
private string $ownerNotificationTemplate;
private string $customerNotificationTemplate;
private string $miniAppUrl;
public function __construct(
string $botToken,
?int $chatId,
string $ownerNotificationTemplate,
string $customerNotificationTemplate,
string $miniAppUrl
) {
$this->botToken = $botToken;
$this->chatId = $chatId;
$this->ownerNotificationTemplate = $ownerNotificationTemplate;
$this->customerNotificationTemplate = $customerNotificationTemplate;
$this->miniAppUrl = $miniAppUrl;
}
public function getBotToken(): string
{
return $this->botToken;
}
public function getChatId(): ?int
{
return $this->chatId;
}
public function getOwnerNotificationTemplate(): string
{
return $this->ownerNotificationTemplate;
}
public function getCustomerNotificationTemplate(): string
{
return $this->customerNotificationTemplate;
}
public function getMiniAppUrl(): string
{
return $this->miniAppUrl;
}
public function toArray(): array
{
return [
'bot_token' => $this->botToken,
'chat_id' => $this->chatId,
'owner_notification_template' => $this->ownerNotificationTemplate,
'customer_notification_template' => $this->customerNotificationTemplate,
'mini_app_url' => $this->miniAppUrl,
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\DTO\Settings;
final class TextsDTO
{
private string $textNoMoreProducts;
private string $textEmptyCart;
private string $textOrderCreatedSuccess;
public function __construct(
string $textNoMoreProducts,
string $textEmptyCart,
string $textOrderCreatedSuccess
) {
$this->textNoMoreProducts = $textNoMoreProducts;
$this->textEmptyCart = $textEmptyCart;
$this->textOrderCreatedSuccess = $textOrderCreatedSuccess;
}
public function getTextNoMoreProducts(): string
{
return $this->textNoMoreProducts;
}
public function getTextEmptyCart(): string
{
return $this->textEmptyCart;
}
public function getTextOrderCreatedSuccess(): string
{
return $this->textOrderCreatedSuccess;
}
public function toArray(): array
{
return [
'text_no_more_products' => $this->textNoMoreProducts,
'text_empty_cart' => $this->textEmptyCart,
'text_order_created_success' => $this->textOrderCreatedSuccess,
];
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Handlers;
use Openguru\OpenCartFramework\Config\Settings;
use App\Services\SettingsService;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\ImageTool\ImageToolInterface;
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
@@ -11,9 +11,9 @@ class BannerHandler
{
private OcRegistryDecorator $registry;
private ImageToolInterface $imageTool;
private Settings $settings;
private SettingsService $settings;
public function __construct(OcRegistryDecorator $registry, ImageToolInterface $imageTool, Settings $settings)
public function __construct(OcRegistryDecorator $registry, ImageToolInterface $imageTool, SettingsService $settings)
{
$this->registry = $registry;
$this->imageTool = $imageTool;
@@ -24,23 +24,20 @@ class BannerHandler
public function show(): JsonResponse
{
$slider = $this->settings->get('mainpage_slider', []);
$slider = $this->settings->config()->getSliders()->getMainpageSlider();
$data = [];
if ($slider && ! empty($slider['slides']) && is_array($slider['slides'])) {
foreach ($slider['slides'] as $index => $slide) {
if (is_file(DIR_IMAGE . $slide['image'])) {
$slider['slides'][$index] = [
'id' => $index,
'title' => $slide['title'],
'link' => $slide['link'],
'image' => $this->imageTool->cover($slide['image'], 1110, 600),
];
}
foreach ($slider->getSlides() as $index => $slide) {
if (is_file(DIR_IMAGE . $slide->getImage())) {
$data['slides'][$index] = [
'id' => $index,
'title' => $slide->getTitle(),
'link' => $slide->getLink(),
'image' => $this->imageTool->cover($slide->getImage(), 1110, 600),
];
}
}
return new JsonResponse([
'data' => $slider,
]);
return new JsonResponse(compact('data'));
}
}

View File

@@ -4,8 +4,8 @@ declare(strict_types=1);
namespace App\Handlers;
use App\Services\SettingsService;
use App\Support\Utils;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\ImageTool\ImageToolInterface;
@@ -18,9 +18,9 @@ class CategoriesHandler
private Builder $queryBuilder;
private ImageToolInterface $ocImageTool;
private Settings $settings;
private SettingsService $settings;
public function __construct(Builder $queryBuilder, ImageToolInterface $ocImageTool, Settings $settings)
public function __construct(Builder $queryBuilder, ImageToolInterface $ocImageTool, SettingsService $settings)
{
$this->queryBuilder = $queryBuilder;
$this->ocImageTool = $ocImageTool;
@@ -29,12 +29,12 @@ class CategoriesHandler
public function index(Request $request): JsonResponse
{
$languageId = $this->settings->get('language_id');
$languageId = $this->settings->config()->getApp()->getLanguageId();
$perPage = $request->get('perPage', 100);
$forMainPage = filter_var($request->get('forMainPage', false), FILTER_VALIDATE_BOOLEAN);
$featuredCategories = $this->settings->get('featured_categories');
$mainpageCategories = $this->settings->get('mainpage_categories');
$featuredCategories = $this->settings->config()->getStore()->getFeaturedCategories();
$mainpageCategories = $this->settings->config()->getStore()->getMainpageCategories();
if ($forMainPage && $mainpageCategories === 'no_categories') {
return new JsonResponse(['data' => []]);

View File

@@ -5,8 +5,8 @@ declare(strict_types=1);
namespace App\Handlers;
use App\Services\ProductsService;
use App\Services\SettingsService;
use Exception;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Exceptions\EntityNotFoundException;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
@@ -16,11 +16,11 @@ use RuntimeException;
class ProductsHandler
{
private Settings $settings;
private SettingsService $settings;
private ProductsService $productsService;
private LoggerInterface $logger;
public function __construct(Settings $settings, ProductsService $productsService, LoggerInterface $logger)
public function __construct(SettingsService $settings, ProductsService $productsService, LoggerInterface $logger)
{
$this->settings = $settings;
$this->productsService = $productsService;
@@ -33,7 +33,7 @@ class ProductsHandler
$perPage = min((int) $request->json('perPage', 6), 15);
$search = trim($request->get('search', ''));
$filters = $request->json('filters');
$languageId = $this->settings->get('language_id');
$languageId = $this->settings->config()->getApp()->getLanguageId();
$response = $this->productsService->getProductsResponse(
compact('page', 'perPage', 'search', 'filters'),

View File

@@ -2,9 +2,9 @@
namespace App\Handlers;
use App\Services\SettingsService;
use Exception;
use GuzzleHttp\Exception\ClientException;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\ImageTool\ImageToolInterface;
@@ -13,13 +13,17 @@ use Openguru\OpenCartFramework\Telegram\TelegramService;
class SettingsHandler
{
private Settings $settings;
private SettingsService $settings;
private ImageToolInterface $imageTool;
private Router $router;
private TelegramService $telegramService;
public function __construct(Settings $settings, ImageToolInterface $imageTool, Router $router, TelegramService $telegramService)
{
public function __construct(
SettingsService $settings,
ImageToolInterface $imageTool,
Router $router,
TelegramService $telegramService
) {
$this->settings = $settings;
$this->imageTool = $imageTool;
$this->router = $router;
@@ -28,49 +32,51 @@ class SettingsHandler
public function index(): JsonResponse
{
$appIcon = $this->settings->get('app_icon');
$appConfig = $this->settings->config()->getApp();
$appIcon = $appConfig->getAppIcon();
$hash = $this->settings->getHash();
$icons = [];
if ($appIcon) {
$icons['icon192'] = $this->imageTool->resize($appIcon, 192, 192, 'no_image.png', 'png'). '?_v=' . $hash;
$icons['icon180'] = $this->imageTool->resize($appIcon, 180, 180, 'no_image.png', 'png'). '?_v=' . $hash;
$icons['icon152'] = $this->imageTool->resize($appIcon, 152, 152, 'no_image.png', 'png'). '?_v=' . $hash;
$icons['icon120'] = $this->imageTool->resize($appIcon, 120, 120, 'no_image.png', 'png'). '?_v=' . $hash;
$appIcon = $this->imageTool->resize($appIcon, 32, 32, 'no_image.png', 'png'). '?_v=' . $hash;
$icons['icon192'] = $this->imageTool->resize($appIcon, 192, 192, 'no_image.png', 'png') . '?_v=' . $hash;
$icons['icon180'] = $this->imageTool->resize($appIcon, 180, 180, 'no_image.png', 'png') . '?_v=' . $hash;
$icons['icon152'] = $this->imageTool->resize($appIcon, 152, 152, 'no_image.png', 'png') . '?_v=' . $hash;
$icons['icon120'] = $this->imageTool->resize($appIcon, 120, 120, 'no_image.png', 'png') . '?_v=' . $hash;
$appIcon = $this->imageTool->resize($appIcon, 32, 32, 'no_image.png', 'png') . '?_v=' . $hash;
}
return new JsonResponse([
'app_name' => $this->settings->get('app_name'),
'app_debug' => $this->settings->get('app_debug'),
'app_icon' => $appIcon ?? '',
'app_name' => $appConfig->getAppName(),
'app_debug' => $appConfig->isAppDebug(),
'app_icon' => $appIcon,
'app_icon192' => $icons['icon192'] ?? '',
'app_icon180' => $icons['icon180'] ?? '',
'app_icon152' => $icons['icon152'] ?? '',
'app_icon120' => $icons['icon120'] ?? '',
'manifest_url' => $this->router->url('manifest', ['_v' => $hash]),
'theme_light' => $this->settings->get('theme_light'),
'theme_dark' => $this->settings->get('theme_dark'),
'ya_metrika_enabled' => $this->settings->get('ya_metrika_enabled'),
'app_enabled' => $this->settings->get('app_enabled'),
'store_enabled' => $this->settings->get('store_enabled'),
'feature_coupons' => $this->settings->get('feature_coupons') ?? false,
'feature_vouchers' => $this->settings->get('feature_vouchers') ?? false,
'currency_code' => $this->settings->get('oc_default_currency', 'RUB'),
'texts' => $this->settings->get('texts'),
'mainpage_slider' => $this->settings->get('mainpage_slider'),
'theme_light' => $appConfig->getThemeLight(),
'theme_dark' => $appConfig->getThemeDark(),
'ya_metrika_enabled' => $this->settings->config()->getMetrics()->isYandexMetrikaEnabled(),
'app_enabled' => $appConfig->isAppEnabled(),
'store_enabled' => $this->settings->config()->getStore()->isEnableStore(),
'feature_coupons' => $this->settings->config()->getStore()->isFeatureCoupons(),
'feature_vouchers' => $this->settings->config()->getStore()->isFeatureVouchers(),
'currency_code' => $this->settings->config()->getStore()->getOcDefaultCurrency(),
'texts' => $this->settings->config()->getTexts()->toArray(),
'mainpage_slider' => $this->settings->config()->getSliders()->getMainpageSlider()->toArray(),
]);
}
public function manifest(): JsonResponse
{
$appIcon = $this->settings->get('app_icon');
$appIcon = $this->settings->config()->getApp()->getAppIcon();
$icon192 = $this->imageTool->resize($appIcon, 192, 192, 'no_image.png', 'png');
$icon512 = $this->imageTool->resize($appIcon, 512, 512, 'no_image.png', 'png');
return new JsonResponse([
'name' => $this->settings->get('app_name'),
'short_name' => $this->settings->get('app_name'),
'name' => $this->settings->config()->getApp()->getAppName(),
'short_name' => $this->settings->config()->getApp()->getAppName(),
'start_url' => '/image/catalog/tgshopspa/',
'display' => 'standalone',
'background_color' => '#ffffff',
@@ -110,7 +116,7 @@ class SettingsHandler
}
$variables = [
'{store_name}' => $this->settings->get('oc_store_name'),
'{store_name}' => $this->settings->config()->getApp()->getAppName(),
'{order_id}' => 777,
'{customer}' => 'Иван Васильевич',
'{email}' => 'telegram@opencart.com',

View File

@@ -47,6 +47,8 @@ class TelegramHandler
*/
public function webhook(Request $request): JsonResponse
{
$this->logger->debug('Webhook received');
$update = $request->json();
$message = $update['message'] ?? null;
if (! $message) {

View File

@@ -0,0 +1,21 @@
<?php
namespace App\ServiceProviders;
use App\Services\SettingsSerializerService;
use App\Services\SettingsService;
use Openguru\OpenCartFramework\Container\Container;
use Openguru\OpenCartFramework\Container\ServiceProvider;
class SettingsServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->container->singleton(SettingsService::class, function (Container $container) {
return new SettingsService(
$container->getConfigValue(),
$container->get(SettingsSerializerService::class)
);
});
}
}

View File

@@ -5,10 +5,8 @@ declare(strict_types=1);
namespace App\Services;
use App\Exceptions\OrderValidationFailedException;
use Cassandra\Date;
use DateTime;
use Exception;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Logger\LoggerInterface;
use Openguru\OpenCartFramework\OpenCart\Decorators\OcRegistryDecorator;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
@@ -23,7 +21,7 @@ class OrderCreateService
private ConnectionInterface $database;
private CartService $cartService;
private OcRegistryDecorator $oc;
private Settings $settings;
private SettingsService $settings;
private TelegramService $telegramService;
private LoggerInterface $logger;
private ValidatorInterface $validator;
@@ -32,7 +30,7 @@ class OrderCreateService
ConnectionInterface $database,
CartService $cartService,
OcRegistryDecorator $registry,
Settings $settings,
SettingsService $settings,
TelegramService $telegramService,
LoggerInterface $logger,
ValidatorInterface $validator
@@ -56,10 +54,10 @@ class OrderCreateService
$now = date('Y-m-d H:i:s');
$storeId = $this->settings->get('oc_store_id');
$storeName = $this->settings->get('oc_store_name');
$orderStatusId = $this->settings->get('oc_order_status_id');
$customerGroupId = $this->settings->get('oc_customer_group_id');
$languageId = $this->oc->config->get('config_language_id');
$storeName = $this->settings->config()->getApp()->getAppName();
$orderStatusId = $this->settings->config()->getOrders()->getOrderDefaultStatusId();
$customerGroupId = $this->settings->config()->getOrders()->getOcCustomerGroupId();
$languageId = $this->settings->config()->getApp()->getLanguageId();
$currencyId = $this->oc->currency->getId($this->oc->session->data['currency']);
$currencyCode = $this->oc->session->data['currency'];
$currencyValue = $this->oc->currency->getValue($this->oc->session->data['currency']);
@@ -233,8 +231,8 @@ class OrderCreateService
'{created_at}' => $orderData['date_added'],
];
$chatId = $this->settings->get('telegram.chat_id');
$template = $this->settings->get('telegram.owner_notification_template');
$chatId = $this->settings->config()->getTelegram()->getChatId();
$template = $this->settings->config()->getTelegram()->getOwnerNotificationTemplate();
if ($chatId && $template) {
$message = $this->telegramService->prepareMessage($template, $variables);
@@ -248,7 +246,7 @@ class OrderCreateService
$allowsWriteToPm = Arr::get($tgInitData, 'user.allows_write_to_pm', false);
$customerChatId = Arr::get($tgInitData, 'user.id');
$template = $this->settings->get('telegram.customer_notification_template');
$template = $this->settings->config()->getTelegram()->getCustomerNotificationTemplate();
if ($allowsWriteToPm && $customerChatId && $template) {
$message = $this->telegramService->prepareMessage($template, $variables);

View File

@@ -6,7 +6,6 @@ use App\Support\Utils;
use Cart\Currency;
use Cart\Tax;
use Exception;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\CriteriaBuilder\CriteriaBuilder;
use Openguru\OpenCartFramework\Exceptions\EntityNotFoundException;
use Openguru\OpenCartFramework\ImageTool\ImageToolInterface;
@@ -24,7 +23,7 @@ class ProductsService
private Builder $queryBuilder;
private Currency $currency;
private Tax $tax;
private Settings $settings;
private SettingsService $settings;
private ImageToolInterface $ocImageTool;
private OcRegistryDecorator $oc;
private LoggerInterface $logger;
@@ -34,7 +33,7 @@ class ProductsService
Builder $queryBuilder,
Currency $currency,
Tax $tax,
Settings $settings,
SettingsService $settings,
ImageToolInterface $ocImageTool,
OcRegistryDecorator $registry,
LoggerInterface $logger,
@@ -61,8 +60,8 @@ class ProductsService
$maxPages = $params['maxPages'] ?? 50;
$filters = $params['filters'] ?? [];
$customerGroupId = (int) $this->settings->get('oc_customer_group_id');
$currency = $this->settings->get('oc_default_currency');
$customerGroupId = $this->settings->config()->getOrders()->getOcCustomerGroupId();
$currency = $this->settings->config()->getStore()->getOcDefaultCurrency();
$specialPriceSql = "(SELECT price
FROM oc_product_special ps
@@ -173,7 +172,7 @@ class ProductsService
$priceNumeric = $this->tax->calculate(
$product['price'],
$product['tax_class_id'],
$this->settings->get('oc_config_tax'),
$this->settings->config()->getStore()->isOcConfigTax(),
);
$price = $this->currency->format($priceNumeric, $currency);
@@ -183,7 +182,7 @@ class ProductsService
$specialPriceNumeric = $this->tax->calculate(
$product['special'],
$product['tax_class_id'],
$this->settings->get('oc_config_tax'),
$this->settings->config()->getStore()->isOcConfigTax(),
);
$special = $this->currency->format(
$specialPriceNumeric,

View File

@@ -0,0 +1,686 @@
<?php
namespace App\Services;
use App\DTO\Settings\AppDTO;
use App\DTO\Settings\ConfigDTO;
use App\DTO\Settings\DatabaseDTO;
use App\DTO\Settings\LogsDTO;
use App\DTO\Settings\MainpageSlider\LinkDTO;
use App\DTO\Settings\MainpageSlider\LinkValueDTO;
use App\DTO\Settings\MainpageSlider\MainpageSliderDTO;
use App\DTO\Settings\MainpageSlider\SlideDTO;
use App\DTO\Settings\MetricsDTO;
use App\DTO\Settings\OrdersDTO;
use App\DTO\Settings\SlidersDTO;
use App\DTO\Settings\StoreDTO;
use App\DTO\Settings\TelegramDTO;
use App\DTO\Settings\TextsDTO;
use InvalidArgumentException;
class SettingsSerializerService
{
public function fromArray(array $data): ConfigDTO
{
$keys = ['app', 'telegram', 'metrics', 'store', 'orders', 'texts', 'sliders', 'database', 'logs'];
foreach ($keys as $key) {
if (! array_key_exists($key, $data)) {
throw new InvalidArgumentException("Settings key '$key' is required!");
}
}
$this->validateApp($data['app']);
$this->validateTelegram($data['telegram']);
$this->validateMetrics($data['metrics']);
$this->validateStore($data['store']);
$this->validateOrders($data['orders']);
$this->validateTexts($data['texts']);
$this->validateSliders($data['sliders']);
$this->validateDatabase($data['database']);
$this->validateLogs($data['logs']);
return new ConfigDTO(
$this->deserializeApp($data['app']),
$this->deserializeTelegram($data['telegram']),
$this->deserializeMetrics($data['metrics']),
$this->deserializeStore($data['store']),
$this->deserializeOrders($data['orders']),
$this->deserializeTexts($data['texts']),
$this->deserializeSliders($data['sliders']),
$this->deserializeDatabase($data['database']),
$this->deserializeLogs($data['logs']),
);
}
/**
* @throws \JsonException
*/
public function serialize(ConfigDTO $settings): string
{
return json_encode($settings->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
private function deserializeApp(array $data): AppDTO
{
if (! isset($data['language_id'])) {
throw new InvalidArgumentException('app.language_id is required');
}
if (! is_int($data['language_id'])) {
throw new InvalidArgumentException('app.language_id must be an integer');
}
if (! isset($data['shop_base_url'])) {
throw new InvalidArgumentException('app.shop_base_url is required');
}
if (! is_string($data['shop_base_url'])) {
throw new InvalidArgumentException('app.shop_base_url must be a string');
}
return new AppDTO(
$data['app_enabled'] ?? false,
$data['app_name'] ?? '',
$data['app_icon'] ?? null,
$data['theme_light'] ?? 'light',
$data['theme_dark'] ?? 'dark',
$data['app_debug'] ?? false,
$data['language_id'],
$data['shop_base_url']
);
}
private function deserializeTelegram(array $data): TelegramDTO
{
if (! isset($data['mini_app_url'])) {
throw new InvalidArgumentException('telegram.mini_app_url is required');
}
if (! is_string($data['mini_app_url'])) {
throw new InvalidArgumentException('telegram.mini_app_url must be a string');
}
return new TelegramDTO(
$data['bot_token'],
$data['chat_id'],
$data['owner_notification_template'],
$data['customer_notification_template'],
$data['mini_app_url']
);
}
private function deserializeMetrics(array $data): MetricsDTO
{
return new MetricsDTO(
$data['yandex_metrika_enabled'] ?? false,
$data['yandex_metrika_counter'] ?? ''
);
}
private function deserializeStore(array $data): StoreDTO
{
if (! isset($data['oc_default_currency'])) {
throw new InvalidArgumentException('store.oc_default_currency is required');
}
if (! is_string($data['oc_default_currency'])) {
throw new InvalidArgumentException('store.oc_default_currency must be a string');
}
if (! isset($data['oc_config_tax'])) {
throw new InvalidArgumentException('store.oc_config_tax is required');
}
if (! is_bool($data['oc_config_tax'])) {
throw new InvalidArgumentException('store.oc_config_tax must be a boolean');
}
if (! isset($data['oc_store_id'])) {
throw new InvalidArgumentException('store.oc_store_id is required');
}
if (! is_int($data['oc_store_id'])) {
throw new InvalidArgumentException('store.oc_store_id must be an integer');
}
return new StoreDTO(
$data['enable_store'] ?? true,
$data['mainpage_products'] ?? 'most_viewed',
$data['featured_products'] ?? [],
$data['mainpage_categories'] ?? 'latest10',
$data['featured_categories'] ?? [],
$data['feature_coupons'] ?? true,
$data['feature_vouchers'] ?? true,
$data['oc_default_currency'],
$data['oc_config_tax'],
$data['oc_store_id']
);
}
private function deserializeOrders(array $data): OrdersDTO
{
if (! isset($data['oc_customer_group_id'])) {
throw new InvalidArgumentException('orders.oc_customer_group_id is required');
}
if (! is_int($data['oc_customer_group_id'])) {
throw new InvalidArgumentException('orders.oc_customer_group_id must be an integer');
}
return new OrdersDTO(
$data['order_default_status_id'] ?? 1,
$data['oc_customer_group_id']
);
}
private function deserializeTexts(array $data): TextsDTO
{
return new TextsDTO(
$data['text_no_more_products'],
$data['text_empty_cart'],
$data['text_order_created_success']
);
}
private function deserializeSliders(array $data): SlidersDTO
{
return new SlidersDTO(
$this->deserializeMainpageSlider($data['mainpage_slider'] ?? [])
);
}
private function deserializeMainpageSlider(array $data): MainpageSliderDTO
{
$slides = [];
if (isset($data['slides']) && is_array($data['slides'])) {
foreach ($data['slides'] as $slideData) {
$slides[] = $this->deserializeSlide($slideData);
}
}
return new MainpageSliderDTO(
$data['is_enabled'] ?? false,
$data['effect'] ?? 'slide',
$data['pagination'] ?? true,
$data['scrollbar'] ?? false,
$data['free_mode'] ?? false,
$data['space_between'] ?? 30,
$data['autoplay'] ?? false,
$data['loop'] ?? false,
$slides
);
}
private function deserializeSlide(array $data): SlideDTO
{
return new SlideDTO(
$data['title'] ?? '',
$this->deserializeLink($data['link'] ?? []),
$data['image'] ?? ''
);
}
private function deserializeLink(array $data): LinkDTO
{
$value = null;
if (isset($data['value'])) {
$value = $this->deserializeLinkValue($data['value']);
}
return new LinkDTO(
$data['type'] ?? 'none',
$value
);
}
private function deserializeLinkValue(array $data): LinkValueDTO
{
return new LinkValueDTO(
$data['category_id'] ?? null,
$data['name'] ?? null,
$data['product_id'] ?? null
);
}
// ==================== Validation Methods ====================
private function validateApp(array $data): void
{
if (! is_bool($data['app_enabled'])) {
throw new InvalidArgumentException('app.app_enabled must be a boolean');
}
if (! is_string($data['app_name'])) {
throw new InvalidArgumentException('app.app_name must be a string');
}
if (isset($data['app_icon']) && ! is_string($data['app_icon'])) {
throw new InvalidArgumentException('app.app_icon must be a string or null');
}
if (! is_string($data['theme_light'])) {
throw new InvalidArgumentException('app.theme_light must be a string');
}
if (! is_string($data['theme_dark'])) {
throw new InvalidArgumentException('app.theme_dark must be a string');
}
if (! is_bool($data['app_debug'])) {
throw new InvalidArgumentException('app.app_debug must be a boolean');
}
if (! isset($data['language_id'])) {
throw new InvalidArgumentException('app.language_id is required');
}
if (! is_int($data['language_id'])) {
throw new InvalidArgumentException('app.language_id must be an integer');
}
if ($data['language_id'] <= 0) {
throw new InvalidArgumentException('app.language_id must be a positive integer');
}
if (! isset($data['shop_base_url'])) {
throw new InvalidArgumentException('app.shop_base_url is required');
}
if (! is_string($data['shop_base_url'])) {
throw new InvalidArgumentException('app.shop_base_url must be a string');
}
}
private function validateTelegram(array $data): void
{
if (isset($data['bot_token']) && ! is_string($data['bot_token'])) {
throw new InvalidArgumentException('telegram.bot_token must be a string or null');
}
if (isset($data['chat_id']) && ! is_int($data['chat_id'])) {
throw new InvalidArgumentException('telegram.chat_id must be an integer or null');
}
if (isset($data['owner_notification_template']) && ! is_string(
$data['owner_notification_template']
)) {
throw new InvalidArgumentException('telegram.owner_notification_template must be a string or null');
}
if (isset($data['customer_notification_template']) && ! is_string(
$data['customer_notification_template']
)) {
throw new InvalidArgumentException('telegram.customer_notification_template must be a string or null');
}
if (! isset($data['mini_app_url'])) {
throw new InvalidArgumentException('telegram.mini_app_url is required');
}
if (! is_string($data['mini_app_url'])) {
throw new InvalidArgumentException('telegram.mini_app_url must be a string');
}
}
private function validateMetrics(array $data): void
{
if (isset($data['yandex_metrika_enabled']) && ! is_bool($data['yandex_metrika_enabled'])) {
throw new InvalidArgumentException('metrics.yandex_metrika_enabled must be a boolean');
}
if (isset($data['yandex_metrika_counter']) && ! is_string($data['yandex_metrika_counter'])) {
throw new InvalidArgumentException('metrics.yandex_metrika_counter must be a string');
}
}
private function validateStore(array $data): void
{
if (isset($data['enable_store']) && ! is_bool($data['enable_store'])) {
throw new InvalidArgumentException('store.enable_store must be a boolean');
}
if (isset($data['mainpage_products']) && ! is_string($data['mainpage_products'])) {
throw new InvalidArgumentException('store.mainpage_products must be a string');
}
if (isset($data['featured_products'])) {
if (! is_array($data['featured_products'])) {
throw new InvalidArgumentException('store.featured_products must be an array');
}
foreach ($data['featured_products'] as $index => $productId) {
if (! is_int($productId)) {
throw new InvalidArgumentException("store.featured_products[$index] must be an integer");
}
if ($productId <= 0) {
throw new InvalidArgumentException("store.featured_products[$index] must be a positive integer");
}
}
}
if (isset($data['mainpage_categories']) && ! is_string($data['mainpage_categories'])) {
throw new InvalidArgumentException('store.mainpage_categories must be a string');
}
if (isset($data['featured_categories'])) {
if (! is_array($data['featured_categories'])) {
throw new InvalidArgumentException('store.featured_categories must be an array');
}
foreach ($data['featured_categories'] as $index => $categoryId) {
if (! is_int($categoryId)) {
throw new InvalidArgumentException("store.featured_categories[$index] must be an integer");
}
if ($categoryId <= 0) {
throw new InvalidArgumentException(
"store.featured_categories[$index] must be a positive integer"
);
}
}
}
if (isset($data['feature_coupons']) && ! is_bool($data['feature_coupons'])) {
throw new InvalidArgumentException('store.feature_coupons must be a boolean');
}
if (isset($data['feature_vouchers']) && ! is_bool($data['feature_vouchers'])) {
throw new InvalidArgumentException('store.feature_vouchers must be a boolean');
}
if (! isset($data['oc_default_currency'])) {
throw new InvalidArgumentException('store.oc_default_currency is required');
}
if (! is_string($data['oc_default_currency'])) {
throw new InvalidArgumentException('store.oc_default_currency must be a string');
}
if (! isset($data['oc_config_tax'])) {
throw new InvalidArgumentException('store.oc_config_tax is required');
}
if (! is_bool($data['oc_config_tax'])) {
throw new InvalidArgumentException('store.oc_config_tax must be a boolean');
}
if (! isset($data['oc_store_id'])) {
throw new InvalidArgumentException('store.oc_store_id is required');
}
if (! is_int($data['oc_store_id'])) {
throw new InvalidArgumentException('store.oc_store_id must be an integer');
}
if ($data['oc_store_id'] < 0) {
throw new InvalidArgumentException('store.oc_store_id must be a positive integer or equals 0');
}
}
private function validateOrders(array $data): void
{
if (isset($data['order_default_status_id'])) {
if (! is_int($data['order_default_status_id'])) {
throw new InvalidArgumentException('orders.order_default_status_id must be an integer');
}
if ($data['order_default_status_id'] <= 0) {
throw new InvalidArgumentException('orders.order_default_status_id must be a positive integer');
}
}
if (! isset($data['oc_customer_group_id'])) {
throw new InvalidArgumentException('orders.oc_customer_group_id is required');
}
if (! is_int($data['oc_customer_group_id'])) {
throw new InvalidArgumentException('orders.oc_customer_group_id must be an integer');
}
if ($data['oc_customer_group_id'] <= 0) {
throw new InvalidArgumentException('orders.oc_customer_group_id must be a positive integer');
}
}
private function validateTexts(array $data): void
{
if (isset($data['text_no_more_products']) && ! is_string($data['text_no_more_products'])) {
throw new InvalidArgumentException('texts.text_no_more_products must be a string');
}
if (isset($data['text_empty_cart']) && ! is_string($data['text_empty_cart'])) {
throw new InvalidArgumentException('texts.text_empty_cart must be a string');
}
if (isset($data['text_order_created_success']) && ! is_string($data['text_order_created_success'])) {
throw new InvalidArgumentException('texts.text_order_created_success must be a string');
}
}
private function validateSliders(array $data): void
{
if (isset($data['mainpage_slider'])) {
if (! is_array($data['mainpage_slider'])) {
throw new InvalidArgumentException('sliders.mainpage_slider must be an object');
}
$this->validateMainpageSlider($data['mainpage_slider']);
}
}
private function validateMainpageSlider(array $data): void
{
if (isset($data['is_enabled']) && ! is_bool($data['is_enabled'])) {
throw new InvalidArgumentException('sliders.mainpage_slider.is_enabled must be a boolean');
}
if (isset($data['effect'])) {
if (! is_string($data['effect'])) {
throw new InvalidArgumentException('sliders.mainpage_slider.effect must be a string');
}
$allowedEffects = ['slide', 'fade', 'cube', 'coverflow', 'flip'];
if (! in_array($data['effect'], $allowedEffects, true)) {
throw new InvalidArgumentException(
'sliders.mainpage_slider.effect must be one of: ' . implode(', ', $allowedEffects)
);
}
}
if (isset($data['pagination']) && ! is_bool($data['pagination'])) {
throw new InvalidArgumentException('sliders.mainpage_slider.pagination must be a boolean');
}
if (isset($data['scrollbar']) && ! is_bool($data['scrollbar'])) {
throw new InvalidArgumentException('sliders.mainpage_slider.scrollbar must be a boolean');
}
if (isset($data['free_mode']) && ! is_bool($data['free_mode'])) {
throw new InvalidArgumentException('sliders.mainpage_slider.free_mode must be a boolean');
}
if (isset($data['space_between'])) {
if (! is_int($data['space_between'])) {
throw new InvalidArgumentException('sliders.mainpage_slider.space_between must be an integer');
}
if ($data['space_between'] < 0) {
throw new InvalidArgumentException(
'sliders.mainpage_slider.space_between must be a non-negative integer'
);
}
}
if (isset($data['autoplay']) && ! is_bool($data['autoplay'])) {
throw new InvalidArgumentException('sliders.mainpage_slider.autoplay must be a boolean');
}
if (isset($data['loop']) && ! is_bool($data['loop'])) {
throw new InvalidArgumentException('sliders.mainpage_slider.loop must be a boolean');
}
if (isset($data['slides'])) {
if (! is_array($data['slides'])) {
throw new InvalidArgumentException('sliders.mainpage_slider.slides must be an array');
}
foreach ($data['slides'] as $index => $slideData) {
if (! is_array($slideData)) {
throw new InvalidArgumentException("sliders.mainpage_slider.slides[$index] must be an object");
}
$this->validateSlide($slideData, $index);
}
}
}
private function validateSlide(array $data, int $index): void
{
if (isset($data['title']) && ! is_string($data['title'])) {
throw new InvalidArgumentException("sliders.mainpage_slider.slides[$index].title must be a string");
}
if (isset($data['link'])) {
if (! is_array($data['link'])) {
throw new InvalidArgumentException("sliders.mainpage_slider.slides[$index].link must be an object");
}
$this->validateLink($data['link'], $index);
}
if (isset($data['image']) && ! is_string($data['image'])) {
throw new InvalidArgumentException("sliders.mainpage_slider.slides[$index].image must be a string");
}
}
private function validateLink(array $data, int $slideIndex): void
{
if (isset($data['type'])) {
if (! is_string($data['type'])) {
throw new InvalidArgumentException(
"sliders.mainpage_slider.slides[$slideIndex].link.type must be a string"
);
}
$allowedTypes = ['none', 'category', 'product'];
if (! in_array($data['type'], $allowedTypes, true)) {
throw new InvalidArgumentException(
"sliders.mainpage_slider.slides[$slideIndex].link.type must be one of: " . implode(
', ',
$allowedTypes
)
);
}
}
if (isset($data['value'])) {
if ($data['value'] !== null) {
if (! is_array($data['value'])) {
throw new InvalidArgumentException(
"sliders.mainpage_slider.slides[$slideIndex].link.value must be an object or null"
);
}
$this->validateLinkValue($data['value'], $data['type'] ?? 'none', $slideIndex);
}
}
}
private function validateLinkValue(array $data, string $linkType, int $slideIndex): void
{
if ($linkType === 'category') {
if (isset($data['category_id'])) {
if (! is_int($data['category_id'])) {
throw new InvalidArgumentException(
"sliders.mainpage_slider.slides[$slideIndex].link.value.category_id must be an integer"
);
}
if ($data['category_id'] <= 0) {
throw new InvalidArgumentException(
"sliders.mainpage_slider.slides[$slideIndex].link.value.category_id must be a positive integer"
);
}
}
if (isset($data['name']) && ! is_string($data['name'])) {
throw new InvalidArgumentException(
"sliders.mainpage_slider.slides[$slideIndex].link.value.name must be a string"
);
}
} elseif ($linkType === 'product') {
if (isset($data['product_id'])) {
if (! is_int($data['product_id'])) {
throw new InvalidArgumentException(
"sliders.mainpage_slider.slides[$slideIndex].link.value.product_id must be an integer"
);
}
if ($data['product_id'] <= 0) {
throw new InvalidArgumentException(
"sliders.mainpage_slider.slides[$slideIndex].link.value.product_id must be a positive integer"
);
}
}
if (isset($data['name']) && ! is_string($data['name'])) {
throw new InvalidArgumentException(
"sliders.mainpage_slider.slides[$slideIndex].link.value.name must be a string"
);
}
}
// Проверяем, что не переданы лишние поля
$allowedFields = ['category_id', 'product_id', 'name'];
foreach (array_keys($data) as $field) {
if (! in_array($field, $allowedFields, true)) {
throw new InvalidArgumentException(
"sliders.mainpage_slider.slides[$slideIndex].link.value contains unknown field: $field"
);
}
}
}
private function deserializeLogs(array $logs): LogsDTO
{
return new LogsDTO(
$logs['path'],
);
}
private function deserializeDatabase(array $data): DatabaseDTO
{
return new DatabaseDTO(
$data['host'] ?? '',
$data['database'] ?? '',
$data['username'] ?? '',
$data['password'] ?? '',
$data['prefix'] ?? '',
$data['port'] ?? 3306
);
}
private function validateDatabase(array $data): void
{
if (isset($data['host']) && ! is_string($data['host'])) {
throw new InvalidArgumentException('database.host must be a string');
}
if (isset($data['database']) && ! is_string($data['database'])) {
throw new InvalidArgumentException('database.database must be a string');
}
if (isset($data['username']) && ! is_string($data['username'])) {
throw new InvalidArgumentException('database.username must be a string');
}
if (isset($data['password']) && ! is_string($data['password'])) {
throw new InvalidArgumentException('database.password must be a string');
}
if (isset($data['prefix']) && ! is_string($data['prefix'])) {
throw new InvalidArgumentException('database.prefix must be a string');
}
if (isset($data['port'])) {
if (! is_int($data['port'])) {
throw new InvalidArgumentException('database.port must be an integer');
}
if ($data['port'] <= 0 || $data['port'] > 65535) {
throw new InvalidArgumentException('database.port must be between 1 and 65535');
}
}
}
private function validateLogs(array $logs): void
{
if (! isset($logs['path'])) {
throw new InvalidArgumentException('Logs path must be set');
}
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Services;
use App\DTO\Settings\ConfigDTO;
use Openguru\OpenCartFramework\Config\Settings;
class SettingsService extends Settings
{
private ConfigDTO $config;
public function __construct(array $config, SettingsSerializerService $serializer)
{
parent::__construct($config);
$this->config = $serializer->fromArray($config);
}
public function config(): ConfigDTO
{
return $this->config;
}
}

View File

@@ -4,6 +4,11 @@ namespace App\Support;
final class Utils
{
/**
* @param string $string
* @return string
* @deprecated use Str::htmlEntityEncode instead
*/
public static function htmlEntityEncode(string $string): string
{
return html_entity_decode($string, ENT_QUOTES | ENT_HTML5, 'UTF-8');

View File

@@ -41,7 +41,7 @@ class TestCase extends BaseTestCase
private function bootstrapApplication(): Application
{
$app = ApplicationFactory::create([
'db' => [
'database' => [
'host' => getenv('DB_HOSTNAME') ?: 'mysql',
'database' => getenv('DB_DATABASE') ?: 'ocstore3',
'username' => getenv('DB_USERNAME') ?: 'root',
@@ -59,6 +59,7 @@ class TestCase extends BaseTestCase
'chat_id' => '123',
'owner_notification_template' => 'Test',
'customer_notification_template' => 'Test',
'mini_app_url' => 'https://example.com',
],
]);

View File

@@ -49,6 +49,39 @@ class ArrTest extends TestCase
$this->assertEquals('default', Arr::get($data, 'nonexistent', 'default'));
}
public function testHas(): void
{
$data = [
'key' => 'value',
'nested' => [
'key' => 'nestedValue',
'deep' => [
'key' => 'deepValue',
],
],
];
// Простые ключи
$this->assertTrue(Arr::has($data, 'key'));
$this->assertFalse(Arr::has($data, 'nonexistent'));
// Dot notation - один уровень
$this->assertTrue(Arr::has($data, 'nested.key'));
$this->assertFalse(Arr::has($data, 'nested.nonexistent'));
// Dot notation - несколько уровней
$this->assertTrue(Arr::has($data, 'nested.deep.key'));
$this->assertFalse(Arr::has($data, 'nested.deep.nonexistent'));
$this->assertFalse(Arr::has($data, 'nested.nonexistent.key'));
// Пустой массив
$this->assertFalse(Arr::has([], 'key'));
// Ключ с точкой в корневом массиве
$dataWithDotKey = ['key.with.dots' => 'value'];
$this->assertTrue(Arr::has($dataWithDotKey, 'key.with.dots'));
}
public function testSet(): void
{
$data = [];
@@ -191,4 +224,443 @@ class ArrTest extends TestCase
$this->assertSame($expected, Arr::mergeArraysRecursively($base, $override));
}
public function testMergeArraysRecursivelyWithDotNotationSimple(): void
{
$base = ['app' => ['name' => 'MyApp']];
$override = ['app.logs.path' => '/var/log'];
$expected = [
'app' => [
'name' => 'MyApp',
'logs' => [
'path' => '/var/log',
],
],
];
$result = Arr::mergeArraysRecursivelyWithDotNotation($base, $override);
$this->assertSame($expected, $result);
}
public function testMergeArraysRecursivelyWithDotNotationOverrideExisting(): void
{
$base = [
'app' => [
'name' => 'MyApp',
'logs' => [
'path' => '/tmp/log',
'level' => 'info',
],
],
];
$override = ['app.logs.path' => '/var/log'];
$expected = [
'app' => [
'name' => 'MyApp',
'logs' => [
'path' => '/var/log',
'level' => 'info',
],
],
];
$result = Arr::mergeArraysRecursivelyWithDotNotation($base, $override);
$this->assertSame($expected, $result);
}
public function testMergeArraysRecursivelyWithDotNotationMultipleKeys(): void
{
$base = ['app' => ['name' => 'MyApp']];
$override = [
'app.logs.path' => '/var/log',
'app.logs.level' => 'debug',
'app.debug' => true,
];
$expected = [
'app' => [
'name' => 'MyApp',
'logs' => [
'path' => '/var/log',
'level' => 'debug',
],
'debug' => true,
],
];
$result = Arr::mergeArraysRecursivelyWithDotNotation($base, $override);
$this->assertSame($expected, $result);
}
public function testMergeArraysRecursivelyWithDotNotationDeepNesting(): void
{
$base = [
'app' => [
'name' => 'MyApp',
],
];
$override = [
'app.logs.path' => '/var/log',
'app.config.database.host' => 'localhost',
'app.config.database.port' => 3306,
];
$expected = [
'app' => [
'name' => 'MyApp',
'logs' => [
'path' => '/var/log',
],
'config' => [
'database' => [
'host' => 'localhost',
'port' => 3306,
],
],
],
];
$result = Arr::mergeArraysRecursivelyWithDotNotation($base, $override);
$this->assertSame($expected, $result);
}
public function testMergeArraysRecursivelyWithDotNotationMixedKeys(): void
{
$base = ['app' => ['name' => 'MyApp']];
$override = [
'app.logs.path' => '/var/log',
'telegram' => ['bot_token' => '123456'],
];
$expected = [
'app' => [
'name' => 'MyApp',
'logs' => [
'path' => '/var/log',
],
],
'telegram' => [
'bot_token' => '123456',
],
];
$result = Arr::mergeArraysRecursivelyWithDotNotation($base, $override);
$this->assertSame($expected, $result);
}
public function testMergeArraysRecursivelyWithDotNotationEmptyBase(): void
{
$base = [];
$override = ['app.logs.path' => '/var/log'];
$expected = [
'app' => [
'logs' => [
'path' => '/var/log',
],
],
];
$result = Arr::mergeArraysRecursivelyWithDotNotation($base, $override);
$this->assertSame($expected, $result);
}
public function testMergeArraysRecursivelyWithDotNotationEmptyOverride(): void
{
$base = ['app' => ['name' => 'MyApp']];
$override = [];
$expected = ['app' => ['name' => 'MyApp']];
$result = Arr::mergeArraysRecursivelyWithDotNotation($base, $override);
$this->assertSame($expected, $result);
}
public function testMergeArraysRecursivelyWithDotNotationOverrideArrayWithValue(): void
{
$base = [
'app' => [
'logs' => [
'path' => '/tmp/log',
'level' => 'info',
],
],
];
$override = ['app.logs' => '/var/log'];
$expected = [
'app' => [
'logs' => '/var/log',
],
];
$result = Arr::mergeArraysRecursivelyWithDotNotation($base, $override);
$this->assertSame($expected, $result);
}
public function testMergeArraysRecursivelyWithDotNotationRealWorldExample(): void
{
$json = [
'module_telecart_settings' => [
'app' => [
'app_enabled' => true,
'app_name' => 'Telecart',
],
'telegram' => [
'bot_token' => 'old_token',
],
],
];
$envOverrides = [
'app.logs.path' => '/var/log/telecart',
'app.app_debug' => true,
'telegram.bot_token' => 'new_token_from_env',
];
$result = Arr::mergeArraysRecursivelyWithDotNotation(
$json['module_telecart_settings'],
$envOverrides
);
$expected = [
'app' => [
'app_enabled' => true,
'app_name' => 'Telecart',
'logs' => [
'path' => '/var/log/telecart',
],
'app_debug' => true,
],
'telegram' => [
'bot_token' => 'new_token_from_env',
],
];
$this->assertSame($expected, $result);
}
public function testGetWithKeysSimpleKeys(): void
{
$array = [
'app_name' => 'MyApp',
'debug_mode' => true,
'default_language' => 'en',
];
$keys = ['app_name', 'debug_mode'];
$result = Arr::getWithKeys($array, $keys);
$expected = [
'app_name' => 'MyApp',
'debug_mode' => true,
];
$this->assertSame($expected, $result);
}
public function testGetWithKeysDotNotation(): void
{
$array = [
'app' => [
'name' => 'MyApp',
'logs' => [
'path' => '/var/log',
'level' => 'debug',
],
],
'telegram' => [
'bot_token' => 'token123',
'chat_id' => 'chat456',
],
];
$keys = ['app.name', 'app.logs.path', 'telegram.bot_token'];
$result = Arr::getWithKeys($array, $keys);
$expected = [
'app' => [
'name' => 'MyApp',
'logs' => [
'path' => '/var/log',
],
],
'telegram' => [
'bot_token' => 'token123',
],
];
$this->assertSame($expected, $result);
}
public function testGetWithKeysNonExistentKeys(): void
{
$array = [
'app' => [
'name' => 'MyApp',
],
'telegram' => [
'bot_token' => 'token123',
],
];
$keys = ['app.name', 'app.nonexistent', 'telegram.bot_token', 'nonexistent.key'];
$result = Arr::getWithKeys($array, $keys);
$expected = [
'app' => [
'name' => 'MyApp',
],
'telegram' => [
'bot_token' => 'token123',
],
];
$this->assertSame($expected, $result);
}
public function testGetWithKeysWithNullValues(): void
{
$array = [
'app' => [
'name' => 'MyApp',
'icon' => null,
'logs' => [
'path' => '/var/log',
],
],
];
$keys = ['app.name', 'app.icon', 'app.logs.path'];
$result = Arr::getWithKeys($array, $keys);
$expected = [
'app' => [
'name' => 'MyApp',
'icon' => null,
'logs' => [
'path' => '/var/log',
],
],
];
$this->assertSame($expected, $result);
}
public function testGetWithKeysEmptyKeysArray(): void
{
$array = [
'app' => [
'name' => 'MyApp',
],
];
$keys = [];
$result = Arr::getWithKeys($array, $keys);
$this->assertSame([], $result);
}
public function testGetWithKeysPreservesStructure(): void
{
$array = [
'app' => [
'name' => 'MyApp',
'config' => [
'database' => [
'host' => 'localhost',
'port' => 3306,
'username' => 'root',
],
],
],
'telegram' => [
'bot_token' => 'token',
],
];
$keys = ['app.config.database.host', 'app.config.database.port'];
$result = Arr::getWithKeys($array, $keys);
$expected = [
'app' => [
'config' => [
'database' => [
'host' => 'localhost',
'port' => 3306,
],
],
],
];
$this->assertSame($expected, $result);
}
public function testGetWithKeysMultipleKeysFromSameBranch(): void
{
$array = [
'app' => [
'name' => 'MyApp',
'logs' => [
'path' => '/var/log',
'level' => 'debug',
'max_files' => 10,
],
],
];
$keys = ['app.logs.path', 'app.logs.level', 'app.logs.max_files'];
$result = Arr::getWithKeys($array, $keys);
$expected = [
'app' => [
'logs' => [
'path' => '/var/log',
'level' => 'debug',
'max_files' => 10,
],
],
];
$this->assertSame($expected, $result);
}
public function testGetWithKeysMixedTypes(): void
{
$array = [
'app' => [
'name' => 'MyApp',
'enabled' => true,
'count' => 42,
'price' => 99.99,
'tags' => ['tag1', 'tag2'],
],
];
$keys = ['app.name', 'app.enabled', 'app.count', 'app.price', 'app.tags'];
$result = Arr::getWithKeys($array, $keys);
$expected = [
'app' => [
'name' => 'MyApp',
'enabled' => true,
'count' => 42,
'price' => 99.99,
'tags' => ['tag1', 'tag2'],
],
];
$this->assertSame($expected, $result);
}
public function testGetWithKeysEmptyArray(): void
{
$array = [];
$keys = ['app.name', 'telegram.bot_token'];
$result = Arr::getWithKeys($array, $keys);
$this->assertSame([], $result);
}
}

View File

@@ -52,7 +52,7 @@ class CriteriaBuilderTest extends TestCase
}
$baseSettings = [
'db' => [
'database' => [
'prefix' => 'oc_',
],
];
@@ -77,7 +77,7 @@ class CriteriaBuilderTest extends TestCase
});
$application->singleton(Settings::class, function () {
return new Settings();
return new Settings([]);
});
/** @var RulesRegistry $rulesRegistry */

View File

@@ -37,17 +37,6 @@ class SettingsTest extends TestCase
$this->assertEquals('default_host', $this->settings->get('database.non_existent', 'default_host'));
}
public function testSet(): void
{
$this->settings->set('app_name', 'NewApp');
$this->assertEquals('NewApp', $this->settings->get('app_name'));
$this->settings->set('new_setting', 'new_value');
$this->assertEquals('new_value', $this->settings->get('new_setting'));
$this->settings->set('database.host', '127.0.0.1');
$this->assertEquals('127.0.0.1', $this->settings->get('database.host'));
}
public function testHas(): void
{
@@ -58,15 +47,6 @@ class SettingsTest extends TestCase
$this->assertFalse($this->settings->has('database.non_existent'));
}
public function testRemove(): void
{
$this->settings->remove('debug_mode');
$this->assertFalse($this->settings->has('debug_mode'));
$this->settings->remove('database.host');
$this->assertFalse($this->settings->has('database.host'));
}
public function testGetAll(): void
{
$expected = [
@@ -81,39 +61,4 @@ class SettingsTest extends TestCase
$this->assertEquals($expected, $this->settings->getAll());
}
public function testSetAll(): void
{
$newSettings = [
'app_name' => 'NewApp',
'theme' => 'dark',
];
$this->settings->setAll($newSettings);
$this->assertEquals($newSettings, $this->settings->getAll());
}
public function testDotNotationGetAndSet(): void
{
$this->settings->set('database.username', 'root');
$this->assertEquals('root', $this->settings->get('database.username'));
$this->settings->set('app.env', 'production');
$this->assertEquals('production', $this->settings->get('app.env'));
}
public function testDotNotationHas(): void
{
$this->settings->set('cache.enabled', true);
$this->assertTrue($this->settings->has('cache.enabled'));
$this->assertFalse($this->settings->has('cache.non_existent'));
}
public function testDotNotationRemove(): void
{
$this->settings->set('logging.level', 'debug');
$this->assertTrue($this->settings->has('logging.level'));
$this->settings->remove('logging.level');
$this->assertFalse($this->settings->has('logging.level'));
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Tests\Unit;
use Openguru\OpenCartFramework\Support\Str;
use PHPUnit\Framework\TestCase;
class StrTest extends TestCase
{
public function testStartsWithSingleNeedle(): void
{
$this->assertTrue(Str::startsWith('hello world', 'hello'));
$this->assertTrue(Str::startsWith('test string', 'test'));
$this->assertFalse(Str::startsWith('hello world', 'world'));
$this->assertFalse(Str::startsWith('hello world', 'foo'));
}
public function testStartsWithEmptyHaystack(): void
{
$this->assertFalse(Str::startsWith('', 'hello'));
$this->assertTrue(Str::startsWith('', ''));
}
public function testStartsWithEmptyNeedle(): void
{
$this->assertFalse(Str::startsWith('hello', ''));
}
public function testStartsWithMultipleNeedles(): void
{
$this->assertTrue(Str::startsWith('hello world', ['hello', 'foo']));
$this->assertTrue(Str::startsWith('test string', ['foo', 'test']));
$this->assertFalse(Str::startsWith('hello world', ['foo', 'bar']));
}
public function testStartsWithCaseSensitive(): void
{
$this->assertTrue(Str::startsWith('Hello World', 'Hello'));
$this->assertFalse(Str::startsWith('Hello World', 'hello'));
$this->assertFalse(Str::startsWith('hello world', 'Hello'));
}
public function testStartsWithExactMatch(): void
{
$this->assertTrue(Str::startsWith('test', 'test'));
}
public function testStartsWithLongNeedle(): void
{
$this->assertTrue(Str::startsWith('short', 'short'));
$this->assertFalse(Str::startsWith('short', 'short text'));
}
}