feat: add Telegram customers management system with admin panel
Implement comprehensive Telegram customers storage and management functionality: Backend: - Add database migration for telecart_customers table with indexes - Create TelegramCustomer model with CRUD operations - Implement TelegramCustomerService for business logic - Add TelegramCustomerHandler for API endpoint (saveOrUpdate) - Add TelegramCustomersHandler for admin API (getCustomers with pagination, filtering, sorting) - Add SendMessageHandler for sending messages to customers via Telegram - Create custom exceptions: TelegramCustomerNotFoundException, TelegramCustomerWriteNotAllowedException - Refactor TelegramInitDataDecoder to separate decoding logic - Add TelegramHeader enum for header constants - Update SignatureValidator to use TelegramInitDataDecoder - Register new routes in bastion/routes.php and src/routes.php Frontend (Admin): - Add CustomersView.vue component with PrimeVue DataTable - Implement advanced filtering (text, date, boolean filters) - Add column visibility toggle functionality - Add global search with debounce - Implement message sending dialog with validation - Add Russian locale for PrimeVue components - Add navigation link in App.vue - Register route in router Frontend (SPA): - Add saveTelegramCustomer utility function - Integrate automatic customer data saving on app initialization - Extract user data from Telegram.WebApp.initDataUnsafe The system automatically saves/updates customer data when users access the Telegram Mini App, and provides admin interface for viewing, filtering, and messaging customers. BREAKING CHANGE: None
This commit is contained in:
39
.cursor/agents.md
Normal file
39
.cursor/agents.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Cursor AI Agents Configuration
|
||||
|
||||
## Роли и правила поведения ИИ
|
||||
|
||||
### Основная роль: Senior Full-Stack Developer
|
||||
|
||||
Вы - опытный full-stack разработчик, специализирующийся на:
|
||||
|
||||
- OpenCart модульной разработке
|
||||
- Кастомных фреймворках (OpenCart Framework)
|
||||
- PHP 7.4+ с современными практиками
|
||||
- Vue.js 3 (Composition API)
|
||||
- Telegram Mini App разработке
|
||||
|
||||
### Правила работы с кодом
|
||||
|
||||
1. **Всегда используй существующие паттерны проекта**
|
||||
2. **Не создавай дубликаты - используй существующие утилиты**
|
||||
3. **Следуй соглашениям именования проекта**
|
||||
4. **Тестируй изменения перед коммитом**
|
||||
5. **Документируй публичные API**
|
||||
|
||||
### Запрещено
|
||||
|
||||
- Хардкод значений (используй конфиги/настройки)
|
||||
- Игнорирование обработки ошибок
|
||||
- Создание циклических зависимостей
|
||||
|
||||
Для разработки FrontEnd используй:
|
||||
|
||||
- Vue.js 3 (Composition API)
|
||||
- Старайся избегать функций watch там, где это возможно и где можно сделать более красиво.
|
||||
- Для frontend/admin используй Tailwind 4 с префиксом `tw:`.
|
||||
- Для frontend/spa используй Tailwind 4 без префикса.
|
||||
- Для frontend/admin используй иконки от FontAwesome 4, потому что это уже встроено в OpenCart 3.
|
||||
- Для frontend/admin используй компоненты VuePrime 4.
|
||||
- Для frontend/spa используй Daisy UI.
|
||||
- Чтобы получить название стандартной таблицы OpenCart, используй хелпер `db_table`, либо добавляй константу DB_PREFIX перед названием таблицы. Так ты получишь название таблицы с префиксом.
|
||||
- Все таблицы моего модуля TeleCart начинаются с префикса `telecart_`. Примеры миграций лежат в `module/oc_telegram_shop/upload/oc_telegram_shop/database/migrations`
|
||||
44
.cursor/config.json
Normal file
44
.cursor/config.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"rules": {
|
||||
"preferCompositionAPI": true,
|
||||
"strictTypes": true,
|
||||
"noHardcodedValues": true,
|
||||
"useDependencyInjection": true
|
||||
},
|
||||
"paths": {
|
||||
"telecart_module": "module/oc_telegram_shop/upload/oc_telegram_shop",
|
||||
"frontendAdmin": "frontend/admin",
|
||||
"telegramShopSpa": "frontend/spa",
|
||||
"migrations": "module/oc_telegram_shop/upload/oc_telegram_shop/database/migrations",
|
||||
"telecartHandlers": "module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers",
|
||||
"adminHandlers": "module/oc_telegram_shop/upload/oc_telegram_shop/bastion/Handlers",
|
||||
"models": "module/oc_telegram_shop/upload/oc_telegram_shop/src/Models",
|
||||
"framework": "module/oc_telegram_shop/upload/oc_telegram_shop/framework"
|
||||
},
|
||||
"naming": {
|
||||
"classes": "PascalCase",
|
||||
"methods": "camelCase",
|
||||
"variables": "camelCase",
|
||||
"constants": "UPPER_SNAKE_CASE",
|
||||
"files": "PascalCase for classes, kebab-case for others",
|
||||
"tables": "snake_case with telecart_ prefix"
|
||||
},
|
||||
"php": {
|
||||
"version": "7.4+",
|
||||
"preferVersion": "7.4+",
|
||||
"psr12": true
|
||||
},
|
||||
"javascript": {
|
||||
"version": "ES2020+",
|
||||
"framework": "Vue 3 Composition API",
|
||||
"stateManagement": "Pinia",
|
||||
"uiLibrary": "PrimeVue (admin), Tailwind (spa)"
|
||||
},
|
||||
"database": {
|
||||
"queryBuilder": true,
|
||||
"migrations": true,
|
||||
"tablePrefix": "telecart_",
|
||||
"noForeignKeys": true
|
||||
}
|
||||
}
|
||||
|
||||
128
.cursor/prompts/api-generation.md
Normal file
128
.cursor/prompts/api-generation.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Промпты для генерации API
|
||||
|
||||
## Создание нового API endpoint
|
||||
|
||||
```
|
||||
Создай новый API endpoint [ENDPOINT_NAME] для [DESCRIPTION]:
|
||||
|
||||
1. Handler в [HANDLER_PATH]:
|
||||
- Метод handle() принимает Request
|
||||
- Валидация входных данных
|
||||
- Использование Service для бизнес-логики
|
||||
- Возврат JsonResponse с правильной структурой
|
||||
- Обработка ошибок с логированием
|
||||
|
||||
2. Service в [SERVICE_PATH]:
|
||||
- Бизнес-логика
|
||||
- Работа с Model
|
||||
- Валидация данных
|
||||
- Обработка исключений
|
||||
|
||||
3. Model в [MODEL_PATH] (если нужен):
|
||||
- Методы для работы с БД
|
||||
- Использование Query Builder
|
||||
- Типизация методов
|
||||
|
||||
4. Route в routes.php:
|
||||
- Добавь маршрут с правильным именем
|
||||
|
||||
5. Миграция (если нужна новая таблица):
|
||||
- Создай миграцию в database/migrations/
|
||||
- Используй фиксированный префикс telecart_
|
||||
- Добавь индексы где необходимо
|
||||
|
||||
Следуй архитектуре MVC-L проекта и используй существующие паттерны.
|
||||
```
|
||||
|
||||
## Создание CRUD API
|
||||
|
||||
```
|
||||
Создай полный CRUD API для сущности [ENTITY_NAME]:
|
||||
|
||||
1. Handler с методами:
|
||||
- list() - список с пагинацией и фильтрацией
|
||||
- get() - получение одной записи
|
||||
- create() - создание
|
||||
- update() - обновление
|
||||
- delete() - удаление
|
||||
|
||||
2. Service с бизнес-логикой для всех операций
|
||||
|
||||
3. Model с методами:
|
||||
- findAll() - список
|
||||
- findById() - по ID
|
||||
- create() - создание
|
||||
- update() - обновление
|
||||
- delete() - удаление
|
||||
|
||||
4. DTO для валидации данных
|
||||
|
||||
5. Миграция для таблицы [TABLE_NAME]
|
||||
|
||||
6. Routes для всех endpoints
|
||||
|
||||
Используй серверную пагинацию, фильтрацию и сортировку для list().
|
||||
```
|
||||
|
||||
## Создание Admin API endpoint
|
||||
|
||||
```
|
||||
Создай Admin API endpoint [ENDPOINT_NAME] в bastion/Handlers/:
|
||||
|
||||
1. Handler в bastion/Handlers/[HANDLER_NAME].php:
|
||||
- Используй Request для получения параметров
|
||||
- Валидация данных
|
||||
- Работа через Service
|
||||
- Возврат JsonResponse с структурой { data: { data: [...], totalRecords: ... } }
|
||||
- Обработка ошибок
|
||||
|
||||
2. Service в bastion/Services/ (если нужен):
|
||||
- Бизнес-логика для админки
|
||||
- Работа с Models
|
||||
|
||||
3. Route в bastion/routes.php
|
||||
|
||||
4. Frontend компонент (если нужен UI):
|
||||
- Vue компонент в frontend/admin/src/views/
|
||||
- Используй PrimeVue компоненты
|
||||
- Серверная пагинация/фильтрация
|
||||
- Обработка ошибок с toast уведомлениями
|
||||
|
||||
Следуй существующим паттернам проекта.
|
||||
```
|
||||
|
||||
## Создание Frontend API клиента
|
||||
|
||||
```
|
||||
Создай функцию для работы с API endpoint [ENDPOINT_NAME]:
|
||||
|
||||
1. В frontend/[admin|spa]/src/utils/http.js:
|
||||
- Функция api[Method] для вызова endpoint
|
||||
- Правильная обработка ошибок
|
||||
- Возврат структурированного ответа
|
||||
|
||||
2. Использование:
|
||||
- В компонентах через import
|
||||
- Обработка loading states
|
||||
- Toast уведомления для ошибок
|
||||
|
||||
Следуй существующим паттернам в http.js.
|
||||
```
|
||||
|
||||
## Создание миграции
|
||||
|
||||
```
|
||||
Создай миграцию для таблицы [TABLE_NAME]:
|
||||
|
||||
1. Файл: database/migrations/[TIMESTAMP]_[DESCRIPTION].php
|
||||
2. Используй фиксированный префикс telecart_ для таблицы
|
||||
3. Добавь все необходимые поля с правильными типами
|
||||
4. Добавь индексы для часто используемых полей
|
||||
5. Используй utf8mb4_unicode_ci collation
|
||||
6. Используй InnoDB engine
|
||||
7. Добавь created_at и updated_at timestamps
|
||||
8. Не создавай foreign keys (используй только индексы)
|
||||
|
||||
Следуй структуре существующих миграций.
|
||||
```
|
||||
|
||||
62
.cursor/prompts/documentation.md
Normal file
62
.cursor/prompts/documentation.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Промпты для документирования
|
||||
|
||||
## Документирование класса
|
||||
|
||||
```
|
||||
Добавь PHPDoc документацию для класса [CLASS_NAME]:
|
||||
1. Описание класса и его назначения
|
||||
2. @package тег
|
||||
3. @author тег
|
||||
4. Документация для всех публичных методов
|
||||
5. Документация для публичных свойств
|
||||
6. Примеры использования где уместно
|
||||
```
|
||||
|
||||
## Документирование метода
|
||||
|
||||
```
|
||||
Добавь PHPDoc для метода [METHOD_NAME]:
|
||||
1. Описание метода
|
||||
2. @param для всех параметров с типами
|
||||
3. @return с типом возвращаемого значения
|
||||
4. @throws для всех исключений
|
||||
5. Примеры использования если сложная логика
|
||||
```
|
||||
|
||||
## Документирование API endpoint
|
||||
|
||||
```
|
||||
Создай документацию для API endpoint [ENDPOINT_NAME]:
|
||||
1. Описание назначения
|
||||
2. HTTP метод и путь
|
||||
3. Параметры запроса (query/body)
|
||||
4. Формат ответа (JSON структура)
|
||||
5. Коды ошибок
|
||||
6. Примеры запросов/ответов
|
||||
7. Требования к авторизации
|
||||
```
|
||||
|
||||
## Документирование Vue компонента
|
||||
|
||||
```
|
||||
Добавь документацию для Vue компонента [COMPONENT_NAME]:
|
||||
1. Описание компонента
|
||||
2. Props с типами и описаниями
|
||||
3. Emits с описаниями
|
||||
4. Slots если есть
|
||||
5. Примеры использования
|
||||
6. Зависимости от других компонентов
|
||||
```
|
||||
|
||||
## Создание README
|
||||
|
||||
```
|
||||
Создай README.md для [MODULE/COMPONENT]:
|
||||
1. Описание назначения
|
||||
2. Установка/настройка
|
||||
3. Использование с примерами
|
||||
4. API документация
|
||||
5. Конфигурация
|
||||
6. Troubleshooting
|
||||
```
|
||||
|
||||
88
.cursor/prompts/refactoring.md
Normal file
88
.cursor/prompts/refactoring.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Промпты для рефакторинга
|
||||
|
||||
## Общий рефакторинг
|
||||
|
||||
```
|
||||
Проанализируй код в файле [FILE_PATH] и выполни рефакторинг:
|
||||
1. Убери дублирование кода
|
||||
2. Улучши читаемость
|
||||
3. Примени принципы SOLID
|
||||
4. Добавь обработку ошибок где необходимо
|
||||
5. Улучши типизацию
|
||||
6. Добавь документацию для публичных методов
|
||||
7. Убедись что код следует архитектуре MVC-L проекта
|
||||
8. Используй существующие утилиты и сервисы проекта вместо создания новых
|
||||
```
|
||||
|
||||
## Рефакторинг Handler
|
||||
|
||||
```
|
||||
Рефакторинг Handler [HANDLER_NAME]:
|
||||
1. Вынеси бизнес-логику в отдельный Service
|
||||
2. Добавь валидацию входных данных
|
||||
3. Улучши обработку ошибок с логированием
|
||||
4. Используй DTO для передачи данных
|
||||
5. Добавь PHPDoc комментарии
|
||||
6. Убедись что используется Dependency Injection
|
||||
7. Оптимизируй запросы к БД если необходимо
|
||||
```
|
||||
|
||||
## Рефакторинг Model
|
||||
|
||||
```
|
||||
Рефакторинг Model [MODEL_NAME]:
|
||||
1. Убедись что все запросы используют Query Builder
|
||||
2. Добавь методы для частых операций (findBy, findAll, create, update)
|
||||
3. Добавь валидацию данных перед сохранением
|
||||
4. Улучши типизацию методов
|
||||
5. Добавь PHPDoc комментарии
|
||||
6. Используй транзакции для сложных операций
|
||||
```
|
||||
|
||||
## Рефакторинг Vue компонента
|
||||
|
||||
```
|
||||
Рефакторинг Vue компонента [COMPONENT_NAME]:
|
||||
1. Вынеси логику в composable функции
|
||||
2. Улучши типизацию props и emits
|
||||
3. Оптимизируй computed properties
|
||||
4. Добавь обработку ошибок
|
||||
5. Улучши структуру template
|
||||
6. Добавь loading states
|
||||
7. Используй существующие утилиты проекта
|
||||
```
|
||||
|
||||
## Удаление дублирования
|
||||
|
||||
```
|
||||
Найди и устрани дублирование кода в:
|
||||
- [FILE_PATH_1]
|
||||
- [FILE_PATH_2]
|
||||
- [FILE_PATH_3]
|
||||
|
||||
Создай общие утилиты/сервисы где необходимо, следуя архитектуре проекта.
|
||||
```
|
||||
|
||||
## Улучшение производительности
|
||||
|
||||
```
|
||||
Проанализируй производительность кода в [FILE_PATH]:
|
||||
1. Оптимизируй запросы к БД (используй индексы, избегай N+1)
|
||||
2. Добавь кэширование где уместно
|
||||
3. Оптимизируй алгоритмы
|
||||
4. Уменьши количество запросов к API
|
||||
5. Используй ленивую загрузку на фронтенде
|
||||
```
|
||||
|
||||
## Улучшение безопасности
|
||||
|
||||
```
|
||||
Улучши безопасность кода в [FILE_PATH]:
|
||||
1. Добавь валидацию всех входных данных
|
||||
2. Используй prepared statements (Query Builder)
|
||||
3. Добавь CSRF защиту где необходимо
|
||||
4. Валидируй права доступа
|
||||
5. Санитизируй выходные данные
|
||||
6. Добавь rate limiting где необходимо
|
||||
```
|
||||
|
||||
53
.cursor/prompts/testing.md
Normal file
53
.cursor/prompts/testing.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Промпты для тестирования
|
||||
|
||||
## Создание unit теста
|
||||
|
||||
```
|
||||
Создай unit тест для [CLASS_NAME] в tests/Unit/:
|
||||
|
||||
1. Используй PHPUnit
|
||||
2. Покрой все публичные методы
|
||||
3. Тестируй успешные сценарии
|
||||
4. Тестируй обработку ошибок
|
||||
5. Используй моки для зависимостей
|
||||
6. Следуй структуре существующих тестов
|
||||
7. Используй TestCase базовый класс проекта
|
||||
```
|
||||
|
||||
## Создание integration теста
|
||||
|
||||
```
|
||||
Создай integration тест для [FEATURE_NAME] в tests/Integration/:
|
||||
|
||||
1. Тестируй полный flow от запроса до ответа
|
||||
2. Используй тестовую БД
|
||||
3. Очищай данные после тестов
|
||||
4. Тестируй реальные сценарии использования
|
||||
5. Проверяй валидацию данных
|
||||
6. Проверяй обработку ошибок
|
||||
```
|
||||
|
||||
## Создание Vue компонент теста
|
||||
|
||||
```
|
||||
Создай тест для Vue компонента [COMPONENT_NAME] в frontend/[admin|spa]/tests/:
|
||||
|
||||
1. Используй Vitest
|
||||
2. Тестируй рендеринг компонента
|
||||
3. Тестируй props
|
||||
4. Тестируй события (emits)
|
||||
5. Тестируй пользовательские взаимодействия
|
||||
6. Используй моки для API вызовов
|
||||
7. Следуй структуре существующих тестов
|
||||
```
|
||||
|
||||
## Покрытие тестами
|
||||
|
||||
```
|
||||
Проанализируй покрытие тестами для [FILE_PATH]:
|
||||
1. Определи какие методы не покрыты тестами
|
||||
2. Создай тесты для критичных методов
|
||||
3. Убедись что тестируются граничные случаи
|
||||
4. Добавь тесты для обработки ошибок
|
||||
```
|
||||
|
||||
201
.cursor/rules/architecture.md
Normal file
201
.cursor/rules/architecture.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# Архитектурные правила
|
||||
|
||||
## OpenCart Framework Architecture
|
||||
|
||||
### MVC-L Pattern
|
||||
|
||||
Проект использует модифицированный паттерн MVC-L (Model-View-Controller-Language):
|
||||
|
||||
- **Model**: Классы в `src/Models/` - работа с данными, доступ к БД
|
||||
- **View**: Vue компоненты на фронтенде, JSON ответы на бэкенде
|
||||
- **Controller**: Handlers в `src/Handlers/` и `bastion/Handlers/`
|
||||
- **Language**: Переводчик в `framework/Translator/`
|
||||
|
||||
### Dependency Injection
|
||||
|
||||
Все зависимости внедряются через Container:
|
||||
|
||||
```php
|
||||
// ✅ Правильно
|
||||
public function __construct(
|
||||
private Builder $builder,
|
||||
private TelegramCustomer $telegramCustomerModel
|
||||
) {}
|
||||
|
||||
// ❌ Неправильно
|
||||
public function __construct() {
|
||||
$this->builder = new Builder(...);
|
||||
}
|
||||
```
|
||||
|
||||
### Service Providers
|
||||
|
||||
Регистрация сервисов через Service Providers:
|
||||
|
||||
```php
|
||||
class MyServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(MyService::class, function ($app) {
|
||||
return new MyService($app->get(Dependency::class));
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Routes
|
||||
|
||||
Маршруты определяются в `routes.php`:
|
||||
|
||||
```php
|
||||
return [
|
||||
'actionName' => [HandlerClass::class, 'methodName'],
|
||||
];
|
||||
```
|
||||
|
||||
### Handlers (Controllers)
|
||||
|
||||
Handlers обрабатывают HTTP запросы:
|
||||
|
||||
```php
|
||||
class MyHandler
|
||||
{
|
||||
public function handle(Request $request): JsonResponse
|
||||
{
|
||||
// Валидация
|
||||
// Бизнес-логика через Services
|
||||
// Возврат JsonResponse
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Models
|
||||
|
||||
Models работают с данными:
|
||||
|
||||
```php
|
||||
class MyModel
|
||||
{
|
||||
public function __construct(
|
||||
private ConnectionInterface $database,
|
||||
private Builder $builder
|
||||
) {}
|
||||
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
return $this->builder->newQuery()
|
||||
->from($this->tableName)
|
||||
->where('id', '=', $id)
|
||||
->firstOrNull();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Services
|
||||
|
||||
Services содержат бизнес-логику:
|
||||
|
||||
```php
|
||||
class MyService
|
||||
{
|
||||
public function __construct(
|
||||
private MyModel $model
|
||||
) {}
|
||||
|
||||
public function doSomething(array $data): array
|
||||
{
|
||||
// Бизнес-логика
|
||||
return $this->model->create($data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Migrations
|
||||
|
||||
Миграции в `database/migrations/`:
|
||||
|
||||
```php
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$this->database->statement('CREATE TABLE ...');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Query Builder
|
||||
|
||||
Всегда используй Query Builder вместо прямых SQL:
|
||||
|
||||
```php
|
||||
// ✅ Правильно
|
||||
$query = $this->builder->newQuery()
|
||||
->select(['id', 'name'])
|
||||
->from('table_name')
|
||||
->where('status', '=', 'active')
|
||||
->get();
|
||||
|
||||
// ❌ Неправильно
|
||||
$result = $this->database->query("SELECT * FROM table_name WHERE status = 'active'");
|
||||
```
|
||||
|
||||
### Frontend Architecture
|
||||
|
||||
#### Admin Panel (Vue 3)
|
||||
|
||||
- Composition API
|
||||
- Pinia для state management
|
||||
- PrimeVue для UI компонентов
|
||||
- Axios для HTTP запросов
|
||||
- Vue Router для навигации
|
||||
|
||||
#### SPA (Telegram Mini App)
|
||||
|
||||
- Composition API
|
||||
- Pinia stores
|
||||
- Tailwind CSS для стилей
|
||||
- Telegram WebApp API
|
||||
- Vue Router
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Classes**: PascalCase (`TelegramCustomerService`)
|
||||
- **Methods**: camelCase (`getCustomers`)
|
||||
- **Variables**: camelCase (`$customerData`)
|
||||
- **Constants**: UPPER_SNAKE_CASE (`MAX_RETRIES`)
|
||||
- **Files**: PascalCase для классов, kebab-case для остального
|
||||
- **Tables**: snake_case с префиксом `telecart_`
|
||||
|
||||
### Error Handling
|
||||
|
||||
Всегда обрабатывай ошибки:
|
||||
|
||||
```php
|
||||
try {
|
||||
$result = $this->service->doSomething();
|
||||
} catch (SpecificException $e) {
|
||||
$this->logger->error('Error message', ['exception' => $e]);
|
||||
throw new UserFriendlyException('User message');
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Используй конфигурационные файлы в `configs/`:
|
||||
|
||||
```php
|
||||
$config = $this->app->getConfigValue('app.setting_name');
|
||||
```
|
||||
|
||||
### Caching
|
||||
|
||||
Используй Cache Service для кэширования:
|
||||
|
||||
```php
|
||||
$cache = $this->app->get(CacheInterface::class);
|
||||
$value = $cache->get('key', function() {
|
||||
return expensiveOperation();
|
||||
});
|
||||
```
|
||||
|
||||
332
.cursor/rules/javascript.md
Normal file
332
.cursor/rules/javascript.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# JavaScript/TypeScript Code Style Rules
|
||||
|
||||
## JavaScript Version
|
||||
|
||||
- ES2020+ features
|
||||
- Modern async/await
|
||||
- Optional chaining (`?.`)
|
||||
- Nullish coalescing (`??`)
|
||||
- Template literals
|
||||
|
||||
## Code Style
|
||||
|
||||
### Variable Declarations
|
||||
|
||||
```javascript
|
||||
// ✅ Используй const по умолчанию
|
||||
const customers = [];
|
||||
const totalRecords = 0;
|
||||
|
||||
// ✅ let только когда нужно переназначение
|
||||
let currentPage = 1;
|
||||
currentPage = 2;
|
||||
|
||||
// ❌ Не используй var
|
||||
var oldVariable = 'bad';
|
||||
```
|
||||
|
||||
### Arrow Functions
|
||||
|
||||
```javascript
|
||||
// ✅ Предпочтительно для коротких функций
|
||||
const filtered = items.filter(item => item.isActive);
|
||||
|
||||
// ✅ Для методов объектов
|
||||
const api = {
|
||||
get: async (url) => {
|
||||
return await fetch(url);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ Для сложной логики - обычные функции
|
||||
function complexCalculation(data) {
|
||||
// много строк кода
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
### Template Literals
|
||||
|
||||
```javascript
|
||||
// ✅ Предпочтительно
|
||||
const message = `User ${userId} not found`;
|
||||
const url = `${baseUrl}/api/${endpoint}`;
|
||||
|
||||
// ❌ Не используй конкатенацию
|
||||
const message = 'User ' + userId + ' not found';
|
||||
```
|
||||
|
||||
### Optional Chaining
|
||||
|
||||
```javascript
|
||||
// ✅ Используй optional chaining
|
||||
const name = user?.profile?.name;
|
||||
const count = data?.items?.length ?? 0;
|
||||
|
||||
// ❌ Избегай длинных проверок
|
||||
const name = user && user.profile && user.profile.name;
|
||||
```
|
||||
|
||||
### Nullish Coalescing
|
||||
|
||||
```javascript
|
||||
// ✅ Используй ?? для значений по умолчанию
|
||||
const page = params.page ?? 1;
|
||||
const name = user.name ?? 'Unknown';
|
||||
|
||||
// ❌ Не используй || для чисел/булевых
|
||||
const page = params.page || 1; // 0 будет заменено на 1
|
||||
```
|
||||
|
||||
### Destructuring
|
||||
|
||||
```javascript
|
||||
// ✅ Используй деструктуризацию
|
||||
const { data, totalRecords } = response.data;
|
||||
const [first, second] = items;
|
||||
|
||||
// ✅ В параметрах функций
|
||||
function processUser({ id, name, email }) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ С значениями по умолчанию
|
||||
const { page = 1, limit = 20 } = params;
|
||||
```
|
||||
|
||||
### Async/Await
|
||||
|
||||
```javascript
|
||||
// ✅ Предпочтительно
|
||||
async function loadCustomers() {
|
||||
try {
|
||||
const response = await apiGet('getCustomers', params);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Избегай .then() цепочек
|
||||
function loadCustomers() {
|
||||
return apiGet('getCustomers', params)
|
||||
.then(response => response.data)
|
||||
.catch(error => console.error(error));
|
||||
}
|
||||
```
|
||||
|
||||
## Vue.js 3 Composition API
|
||||
|
||||
### Script Setup
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Используй <script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { apiGet } from '@/utils/http.js';
|
||||
|
||||
const customers = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const totalRecords = computed(() => customers.value.length);
|
||||
|
||||
async function loadCustomers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await apiGet('getCustomers');
|
||||
customers.value = result.data || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCustomers();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Reactive State
|
||||
|
||||
```javascript
|
||||
// ✅ Используй ref для примитивов
|
||||
const count = ref(0);
|
||||
const name = ref('');
|
||||
|
||||
// ✅ Используй reactive для объектов
|
||||
import { reactive } from 'vue';
|
||||
const state = reactive({
|
||||
customers: [],
|
||||
loading: false
|
||||
});
|
||||
|
||||
// ✅ Или ref для объектов (предпочтительно)
|
||||
const state = ref({
|
||||
customers: [],
|
||||
loading: false
|
||||
});
|
||||
```
|
||||
|
||||
### Computed Properties
|
||||
|
||||
```javascript
|
||||
// ✅ Используй computed для производных значений
|
||||
const filteredCustomers = computed(() => {
|
||||
return customers.value.filter(c => c.isActive);
|
||||
});
|
||||
|
||||
// ❌ Не используй методы для вычислений
|
||||
function filteredCustomers() {
|
||||
return customers.value.filter(c => c.isActive);
|
||||
}
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Определяй props с типами
|
||||
const props = defineProps({
|
||||
customerId: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
showDetails: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Emits
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Определяй emits
|
||||
const emit = defineEmits(['update', 'delete']);
|
||||
|
||||
function handleUpdate() {
|
||||
emit('update', data);
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Pinia Stores
|
||||
|
||||
```javascript
|
||||
// ✅ Используй setup stores
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
export const useCustomersStore = defineStore('customers', () => {
|
||||
const customers = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const totalRecords = computed(() => customers.value.length);
|
||||
|
||||
async function loadCustomers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await apiGet('getCustomers');
|
||||
customers.value = result.data || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
customers,
|
||||
loading,
|
||||
totalRecords,
|
||||
loadCustomers
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```javascript
|
||||
// ✅ Всегда обрабатывай ошибки
|
||||
async function loadData() {
|
||||
try {
|
||||
const result = await apiGet('endpoint');
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
} else {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error);
|
||||
toast.error('Не удалось загрузить данные');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Variables and Functions
|
||||
|
||||
```javascript
|
||||
// ✅ camelCase
|
||||
const customerData = {};
|
||||
const totalRecords = 0;
|
||||
function loadCustomers() {}
|
||||
|
||||
// ✅ Константы UPPER_SNAKE_CASE
|
||||
const MAX_RETRIES = 3;
|
||||
const API_BASE_URL = '/api';
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
```vue
|
||||
<!-- ✅ PascalCase для компонентов -->
|
||||
<CustomerCard />
|
||||
<ProductsList />
|
||||
```
|
||||
|
||||
### Files
|
||||
|
||||
```javascript
|
||||
// ✅ kebab-case для файлов
|
||||
// customers-view.vue
|
||||
// http-utils.js
|
||||
// customer-service.js
|
||||
```
|
||||
|
||||
## Imports
|
||||
|
||||
```javascript
|
||||
// ✅ Группируй импорты
|
||||
// 1. Vue core
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
|
||||
// 2. Third-party
|
||||
import { apiGet } from '@/utils/http.js';
|
||||
import { useToast } from 'primevue';
|
||||
|
||||
// 3. Local components
|
||||
import CustomerCard from '@/components/CustomerCard.vue';
|
||||
|
||||
// 4. Types (если TypeScript)
|
||||
import type { Customer } from '@/types';
|
||||
```
|
||||
|
||||
## TypeScript (где используется)
|
||||
|
||||
```typescript
|
||||
// ✅ Используй типы
|
||||
interface Customer {
|
||||
id: number;
|
||||
name: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
function getCustomer(id: number): Promise<Customer> {
|
||||
return apiGet(`customers/${id}`);
|
||||
}
|
||||
```
|
||||
|
||||
243
.cursor/rules/php.md
Normal file
243
.cursor/rules/php.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# PHP Code Style Rules
|
||||
|
||||
## PHP Version
|
||||
|
||||
Проект поддерживает PHP 7.4+
|
||||
|
||||
## PSR Standards
|
||||
|
||||
- **PSR-1**: Basic Coding Standard
|
||||
- **PSR-4**: Autoloading Standard
|
||||
- **PSR-12**: Extended Coding Style
|
||||
|
||||
## Code Style
|
||||
|
||||
### Type Declarations
|
||||
|
||||
```php
|
||||
// ✅ Правильно - строгая типизация
|
||||
public function getCustomers(Request $request): JsonResponse
|
||||
{
|
||||
$id = (int) $request->get('id');
|
||||
return new JsonResponse(['data' => $customers]);
|
||||
}
|
||||
|
||||
// ❌ Неправильно - без типов
|
||||
public function getCustomers($request)
|
||||
{
|
||||
return ['data' => $customers];
|
||||
}
|
||||
```
|
||||
|
||||
### Nullable Types
|
||||
|
||||
```php
|
||||
// ✅ Правильно
|
||||
public function findById(?int $id): ?array
|
||||
{
|
||||
if ($id === null) {
|
||||
return null;
|
||||
}
|
||||
return $this->query->where('id', '=', $id)->firstOrNull();
|
||||
}
|
||||
```
|
||||
|
||||
### Strict Types
|
||||
|
||||
Всегда используй `declare(strict_types=1);`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
```
|
||||
|
||||
### Array Syntax
|
||||
|
||||
```php
|
||||
// ✅ Предпочтительно - короткий синтаксис
|
||||
$array = ['key' => 'value'];
|
||||
|
||||
// ❌ Не использовать
|
||||
$array = array('key' => 'value');
|
||||
```
|
||||
|
||||
### String Interpolation
|
||||
|
||||
```php
|
||||
// ✅ Предпочтительно
|
||||
$message = "User {$userId} not found";
|
||||
|
||||
// ✅ Альтернатива
|
||||
$message = sprintf('User %d not found', $userId);
|
||||
```
|
||||
|
||||
### Arrow Functions (PHP 7.4+)
|
||||
|
||||
```php
|
||||
// ✅ Для простых операций
|
||||
$filtered = array_filter($items, fn($item) => $item->isActive());
|
||||
|
||||
// ❌ Для сложной логики - используй обычные функции
|
||||
```
|
||||
|
||||
### Nullsafe Operator (PHP 8.0+)
|
||||
|
||||
```php
|
||||
// ✅ Для PHP 7.4
|
||||
$name = $user && $user->profile ? $user->profile->name : null;
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Classes
|
||||
|
||||
```php
|
||||
// ✅ PascalCase
|
||||
class TelegramCustomerService {}
|
||||
class UserRepository {}
|
||||
```
|
||||
|
||||
### Methods
|
||||
|
||||
```php
|
||||
// ✅ camelCase
|
||||
public function getCustomers(): array {}
|
||||
public function saveOrUpdate(array $data): array {}
|
||||
```
|
||||
|
||||
### Variables
|
||||
|
||||
```php
|
||||
// ✅ camelCase
|
||||
$customerData = [];
|
||||
$totalRecords = 0;
|
||||
```
|
||||
|
||||
### Constants
|
||||
|
||||
```php
|
||||
// ✅ UPPER_SNAKE_CASE
|
||||
private const MAX_RETRIES = 3;
|
||||
public const DEFAULT_PAGE_SIZE = 20;
|
||||
```
|
||||
|
||||
### Private Properties
|
||||
|
||||
```php
|
||||
// ✅ camelCase с модификатором доступа
|
||||
private string $tableName;
|
||||
private Builder $builder;
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
### PHPDoc
|
||||
|
||||
```php
|
||||
/**
|
||||
* @throws ValidationException Если параметры невалидны
|
||||
*/
|
||||
public function getCustomers(Request $request): JsonResponse
|
||||
{
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Inline Comments
|
||||
|
||||
```php
|
||||
// ✅ Полезные комментарии
|
||||
// Применяем фильтры для подсчета общего количества записей
|
||||
$countQuery = $this->buildCountQuery($filters);
|
||||
|
||||
// ❌ Очевидные комментарии
|
||||
// Получаем данные
|
||||
$data = $this->getData();
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Exceptions
|
||||
|
||||
```php
|
||||
// ✅ Специфичные исключения
|
||||
if (!$userId) {
|
||||
throw new InvalidArgumentException('User ID is required');
|
||||
}
|
||||
|
||||
// ✅ Логирование
|
||||
try {
|
||||
$result = $this->service->process();
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Processing failed', [
|
||||
'exception' => $e,
|
||||
'context' => $context,
|
||||
]);
|
||||
throw new ProcessingException('Failed to process', 0, $e);
|
||||
}
|
||||
```
|
||||
|
||||
## Query Builder Usage
|
||||
|
||||
### Always Use Query Builder
|
||||
|
||||
```php
|
||||
// ✅ Правильно
|
||||
$customers = $this->builder->newQuery()
|
||||
->select(['id', 'name', 'email'])
|
||||
->from('telecart_customers')
|
||||
->where('status', '=', 'active')
|
||||
->orderBy('created_at', 'DESC')
|
||||
->get();
|
||||
|
||||
// В крайних случаях можно использовать прямые SQL
|
||||
$result = $this->database->query("SELECT * FROM telecart_customers");
|
||||
```
|
||||
|
||||
### Parameter Binding
|
||||
|
||||
```php
|
||||
// ✅ Query Builder автоматически биндит параметры
|
||||
$query->where('name', 'LIKE', "%{$search}%");
|
||||
|
||||
// ❌ Никогда не конкатенируй значения в SQL, избегай SQL Injection.
|
||||
```
|
||||
|
||||
## Array Access
|
||||
|
||||
### Safe Array Access
|
||||
|
||||
```php
|
||||
// ✅ Используй Arr::get()
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
|
||||
$value = Arr::get($data, 'key', 'default');
|
||||
|
||||
// ❌ Небезопасно
|
||||
$value = $data['key']; // может вызвать ошибку
|
||||
```
|
||||
|
||||
## Return Types
|
||||
|
||||
```php
|
||||
// ✅ Всегда указывай return type
|
||||
public function getData(): array {}
|
||||
public function findById(int $id): ?array {}
|
||||
public function process(): void {}
|
||||
|
||||
// ❌ Без типа
|
||||
public function getData() {}
|
||||
```
|
||||
|
||||
## Visibility Modifiers
|
||||
|
||||
```php
|
||||
// ✅ Всегда указывай модификатор доступа
|
||||
private string $tableName;
|
||||
protected Builder $builder;
|
||||
public function getData(): array {}
|
||||
```
|
||||
|
||||
370
.cursor/rules/vue.md
Normal file
370
.cursor/rules/vue.md
Normal file
@@ -0,0 +1,370 @@
|
||||
# Vue.js 3 Rules
|
||||
|
||||
## Component Structure
|
||||
|
||||
### Template
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ Логическая структура -->
|
||||
<div class="container">
|
||||
<header>
|
||||
<h2>{{ title }}</h2>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<DataTable :value="items" />
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<Button @click="handleSave">Save</Button>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Script Setup
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Всегда используй <script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import { apiGet } from '@/utils/http.js';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['update', 'delete']);
|
||||
|
||||
// State
|
||||
const items = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// Computed
|
||||
const totalItems = computed(() => items.value.length);
|
||||
|
||||
// Methods
|
||||
async function loadItems() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await apiGet('getItems');
|
||||
items.value = result.data || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadItems();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Styles
|
||||
|
||||
```vue
|
||||
<style scoped>
|
||||
/* ✅ Используй scoped стили */
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* ✅ Используй :deep() для доступа к дочерним компонентам */
|
||||
:deep(.p-datatable) {
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Component Naming
|
||||
|
||||
```vue
|
||||
<!-- ✅ PascalCase для компонентов -->
|
||||
<CustomerCard />
|
||||
<ProductsList />
|
||||
<OrderDetails />
|
||||
|
||||
<!-- ✅ kebab-case в шаблоне тоже работает -->
|
||||
<customer-card />
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Всегда определяй типы и валидацию
|
||||
const props = defineProps({
|
||||
customerId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
validator: (value) => value > 0
|
||||
},
|
||||
showDetails: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Emits
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Определяй emits с типами
|
||||
const emit = defineEmits<{
|
||||
update: [id: number, data: object];
|
||||
delete: [id: number];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
// ✅ Или с валидацией
|
||||
const emit = defineEmits({
|
||||
update: (id: number, data: object) => {
|
||||
if (id > 0 && typeof data === 'object') {
|
||||
return true;
|
||||
}
|
||||
console.warn('Invalid emit arguments');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Reactive State
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ ref для примитивов
|
||||
const count = ref(0);
|
||||
const name = ref('');
|
||||
|
||||
// ✅ ref для объектов (предпочтительно)
|
||||
const customer = ref({
|
||||
id: null,
|
||||
name: '',
|
||||
email: ''
|
||||
});
|
||||
|
||||
// ✅ reactive только если нужно
|
||||
import { reactive } from 'vue';
|
||||
const state = reactive({
|
||||
items: [],
|
||||
loading: false
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Computed Properties
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Используй computed для производных значений
|
||||
const filteredItems = computed(() => {
|
||||
return items.value.filter(item => item.isActive);
|
||||
});
|
||||
|
||||
// ✅ Computed с getter/setter
|
||||
const fullName = computed({
|
||||
get: () => `${firstName.value} ${lastName.value}`,
|
||||
set: (value) => {
|
||||
const parts = value.split(' ');
|
||||
firstName.value = parts[0];
|
||||
lastName.value = parts[1];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Event Handlers
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ Используй kebab-case для событий -->
|
||||
<Button @click="handleClick" />
|
||||
<Input @input="handleInput" />
|
||||
<Form @submit.prevent="handleSubmit" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// ✅ Именуй обработчики с префиксом handle
|
||||
function handleClick() {
|
||||
// ...
|
||||
}
|
||||
|
||||
function handleInput(event) {
|
||||
// ...
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
// ...
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Conditional Rendering
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ Используй v-if для условного рендеринга -->
|
||||
<div v-if="loading">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
|
||||
<!-- ✅ v-show для частых переключений -->
|
||||
<div v-show="hasItems">
|
||||
<ItemsList :items="items" />
|
||||
</div>
|
||||
|
||||
<!-- ✅ v-else для альтернатив -->
|
||||
<div v-else>
|
||||
<EmptyState />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Lists
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ Всегда используй :key -->
|
||||
<div v-for="item in items" :key="item.id">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
|
||||
<!-- ✅ Для индексов -->
|
||||
<div v-for="(item, index) in items" :key="`item-${index}`">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Form Handling
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<!-- ✅ Используй v-model -->
|
||||
<InputText v-model="form.name" />
|
||||
<Textarea v-model="form.description" />
|
||||
|
||||
<!-- ✅ Для кастомных компонентов -->
|
||||
<CustomInput v-model="form.email" />
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const form = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
email: ''
|
||||
});
|
||||
|
||||
function handleSubmit() {
|
||||
// Валидация и отправка
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## PrimeVue Components
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ Используй PrimeVue компоненты в админке -->
|
||||
<DataTable
|
||||
:value="customers"
|
||||
:loading="loading"
|
||||
paginator
|
||||
:rows="20"
|
||||
@page="onPage"
|
||||
>
|
||||
<Column field="name" header="Name" sortable />
|
||||
</DataTable>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
```vue
|
||||
<style scoped>
|
||||
/* ✅ Используй scoped -->
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* ✅ :deep() для дочерних компонентов */
|
||||
:deep(.p-datatable) {
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
/* ✅ :slotted() для слотов */
|
||||
:slotted(.header) {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Composition Functions
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Выноси сложную логику в composables
|
||||
import { useCustomers } from '@/composables/useCustomers.js';
|
||||
|
||||
const {
|
||||
customers,
|
||||
loading,
|
||||
loadCustomers,
|
||||
totalRecords
|
||||
} = useCustomers();
|
||||
|
||||
onMounted(() => {
|
||||
loadCustomers();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useToast } from 'primevue';
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const result = await apiGet('endpoint');
|
||||
if (result.success) {
|
||||
data.value = result.data;
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: 'Не удалось загрузить данные'
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -22,8 +22,77 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
src/*
|
||||
# Cursor ignore patterns
|
||||
|
||||
frontend/spa/node_modules
|
||||
frontend/admin/node_modules
|
||||
module/oc_telegram_shop/upload/oc_telegram_shop/vendor
|
||||
module/oc_telegram_shop/upload/image
|
||||
module/oc_telegram_shop/upload/oc_telegram_shop/.phpunit.cache
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
vendor/
|
||||
composer.lock
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*.phar
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
storage/logs/
|
||||
|
||||
# Cache
|
||||
storage/cache/
|
||||
.cache/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Database
|
||||
*.sql
|
||||
*.sqlite
|
||||
*.db
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# OpenCart specific
|
||||
src/upload/system/
|
||||
src/upload/image/cache/
|
||||
src/storage/
|
||||
|
||||
# Test fixtures (large files)
|
||||
tests/fixtures/*.sql
|
||||
tests/fixtures/*.json
|
||||
|
||||
# Documentation builds
|
||||
docs/_build/
|
||||
|
||||
# Coverage reports
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
|
||||
|
||||
3
Makefile
3
Makefile
@@ -49,6 +49,9 @@ lint:
|
||||
phpcs:
|
||||
docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash -c "./vendor/bin/phpcs --standard=PSR12 bastion framework src"
|
||||
|
||||
phpcbf:
|
||||
docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash -c "./vendor/bin/phpcbf --standard=PSR12 bastion framework src"
|
||||
|
||||
test:
|
||||
docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash -c "./vendor/bin/phpunit --testdox tests/"
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@
|
||||
<RouterLink :to="{name: 'formbuilder'}">Форма заказа</RouterLink>
|
||||
</li>
|
||||
|
||||
<li :class="{active: route.name === 'customers'}">
|
||||
<RouterLink :to="{name: 'customers'}">Telegram Покупатели</RouterLink>
|
||||
</li>
|
||||
|
||||
<li :class="{active: route.name === 'logs'}">
|
||||
<RouterLink :to="{name: 'logs'}">Журнал событий</RouterLink>
|
||||
</li>
|
||||
|
||||
@@ -66,3 +66,11 @@ ul.formkit-options label {
|
||||
ul.formkit-options input[type="radio"] {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
input.p-checkbox-input {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,49 @@ onReady(async () => {
|
||||
options: {
|
||||
cssLayer: false, // если используешь Tailwind, отключает layering
|
||||
},
|
||||
},
|
||||
locale: {
|
||||
startsWith: 'Начинается с',
|
||||
contains: 'Содержит',
|
||||
notContains: 'Не содержит',
|
||||
endsWith: 'Заканчивается на',
|
||||
equals: 'Равно',
|
||||
notEquals: 'Не равно',
|
||||
noFilter: 'Без фильтра',
|
||||
lt: 'Меньше чем',
|
||||
lte: 'Меньше или равно',
|
||||
gt: 'Больше чем',
|
||||
gte: 'Больше или равно',
|
||||
dateIs: 'Дата равна',
|
||||
dateIsNot: 'Дата не равна',
|
||||
dateBefore: 'Дата до',
|
||||
dateAfter: 'Дата после',
|
||||
clear: 'Очистить',
|
||||
apply: 'Применить',
|
||||
matchAll: 'Совпадает со всеми',
|
||||
matchAny: 'Совпадает с любым',
|
||||
addRule: 'Добавить правило',
|
||||
removeRule: 'Удалить правило',
|
||||
accept: 'Да',
|
||||
reject: 'Нет',
|
||||
choose: 'Выбрать',
|
||||
upload: 'Загрузить',
|
||||
cancel: 'Отмена',
|
||||
dayNames: ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота'],
|
||||
dayNamesShort: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'],
|
||||
dayNamesMin: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'],
|
||||
monthNames: ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'],
|
||||
monthNamesShort: ['Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн', 'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек'],
|
||||
today: 'Сегодня',
|
||||
weekHeader: 'Неделя',
|
||||
firstDayOfWeek: 1,
|
||||
dateFormat: 'dd.mm.yy',
|
||||
weak: 'Слабый',
|
||||
medium: 'Средний',
|
||||
strong: 'Сильный',
|
||||
passwordPrompt: 'Введите пароль',
|
||||
emptyMessage: 'Нет доступных записей',
|
||||
emptyFilterMessage: 'Нет доступных записей'
|
||||
}
|
||||
});
|
||||
app.use(ToastService);
|
||||
|
||||
@@ -8,19 +8,21 @@ import StoreView from "@/views/StoreView.vue";
|
||||
import MainPageView from "@/views/MainPageView.vue";
|
||||
import LogsView from "@/views/LogsView.vue";
|
||||
import FormBuilderView from "@/views/FormBuilderView.vue";
|
||||
import CustomersView from "@/views/CustomersView.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{path: '/', name: 'general', component: GeneralView},
|
||||
{path: '/orders', name: 'orders', component: OrdersView},
|
||||
{path: '/texts', name: 'texts', component: TextsView},
|
||||
{path: '/telegram', name: 'telegram', component: TelegramView},
|
||||
{path: '/metrics', name: 'metrics', component: MetricsView},
|
||||
{path: '/store', name: 'store', component: StoreView},
|
||||
{path: '/mainpage', name: 'mainpage', component: MainPageView},
|
||||
{path: '/logs', name: 'logs', component: LogsView},
|
||||
{path: '/customers', name: 'customers', component: CustomersView},
|
||||
{path: '/formbuilder', name: 'formbuilder', component: FormBuilderView},
|
||||
{path: '/logs', name: 'logs', component: LogsView},
|
||||
{path: '/mainpage', name: 'mainpage', component: MainPageView},
|
||||
{path: '/metrics', name: 'metrics', component: MetricsView},
|
||||
{path: '/orders', name: 'orders', component: OrdersView},
|
||||
{path: '/store', name: 'store', component: StoreView},
|
||||
{path: '/telegram', name: 'telegram', component: TelegramView},
|
||||
{path: '/texts', name: 'texts', component: TextsView},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
549
frontend/admin/src/views/CustomersView.vue
Normal file
549
frontend/admin/src/views/CustomersView.vue
Normal file
@@ -0,0 +1,549 @@
|
||||
<template>
|
||||
<div>
|
||||
<DataTable
|
||||
:value="customers"
|
||||
:loading="loading"
|
||||
paginator
|
||||
:rows="20"
|
||||
:rowsPerPageOptions="[10, 20, 50, 100]"
|
||||
:sortField="lazyParams.sortField"
|
||||
:sortOrder="lazyParams.sortOrder"
|
||||
showGridlines
|
||||
stripedRows
|
||||
size="small"
|
||||
removableSort
|
||||
:globalFilterFields="['telegram_user_id', 'username', 'first_name', 'last_name', 'language_code']"
|
||||
v-model:filters="filters"
|
||||
filterDisplay="menu"
|
||||
:lazy="true"
|
||||
:totalRecords="totalRecords"
|
||||
@page="onPage"
|
||||
@sort="onSort"
|
||||
@filter="onFilter"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
:currentPageReportTemplate="`Показано {first} - {last} из {totalRecords} записей`"
|
||||
>
|
||||
<template #header>
|
||||
<div class="tw:flex tw:flex-wrap tw:items-center tw:justify-between tw:gap-2">
|
||||
<div class="tw:flex tw:items-center tw:gap-2">
|
||||
<Button
|
||||
icon="fa fa-columns"
|
||||
:label="`Колонки (${selectedColumns.length}/${columns.length})`"
|
||||
@click="toggleColumnsPanel"
|
||||
size="small"
|
||||
/>
|
||||
<OverlayPanel ref="columnsPanel">
|
||||
<div class="tw:flex tw:flex-col tw:gap-2 tw:min-w-[200px]">
|
||||
<div class="tw:flex tw:gap-2 tw:mb-2">
|
||||
<Button
|
||||
label="Выбрать все"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="selectAllColumns"
|
||||
class="tw:flex-1"
|
||||
/>
|
||||
<Button
|
||||
label="Снять все"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="deselectAllColumns"
|
||||
class="tw:flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="col in columns"
|
||||
:key="col.field"
|
||||
class="tw:flex tw:items-center tw:gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
:inputId="col.field"
|
||||
:modelValue="selectedColumns.some(c => c.field === col.field)"
|
||||
@update:modelValue="(val) => toggleColumn(col, val)"
|
||||
:binary="true"
|
||||
/>
|
||||
<label :for="col.field" class="tw:cursor-pointer">{{ col.header }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</OverlayPanel>
|
||||
<Button icon="fa fa-refresh" @click="loadCustomers" v-tooltip.top="'Обновить таблицу'" size="small"/>
|
||||
<Button icon="fa fa-times-circle" label="Сбросить фильтры" @click="resetFilters" v-tooltip.top="'Сбросить все фильтры'" size="small"/>
|
||||
</div>
|
||||
|
||||
<IconField>
|
||||
<InputIcon class="fa fa-search" />
|
||||
<InputText v-model="globalSearchValue" placeholder="Поиск по таблице..." @input="onGlobalSearch" />
|
||||
</IconField>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Column header="Действия" :exportable="false" headerStyle="width: 5rem">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
icon="fa fa-paper-plane"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
@click="openMessageDialog(data)"
|
||||
v-tooltip.top="'Отправить сообщение пользователю в Telegram'"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column v-for="col in selectedColumns" :key="col.field" :field="col.field" :header="col.header" :sortable="col.sortable" :dataType="col.dataType" :showFilterMenu="col.filterable !== false">
|
||||
<template #body="{ data }">
|
||||
<template v-if="col.field === 'id'">{{ data.id }}</template>
|
||||
<template v-else-if="col.field === 'telegram_user_id'">{{ data.telegram_user_id }}</template>
|
||||
<template v-else-if="col.field === 'username'">
|
||||
<div class="tw:flex tw:items-center tw:gap-2">
|
||||
<div v-if="data.photo_url" class="tw:relative">
|
||||
<img
|
||||
:src="data.photo_url"
|
||||
:alt="data.username || 'Avatar'"
|
||||
class="tw:w-6 tw:h-6 tw:rounded-full tw:object-cover"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
<i v-else class="fa fa-user tw:text-gray-400"></i>
|
||||
<span v-if="data.username">@{{ data.username }}</span>
|
||||
<span v-else>—</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'first_name'">{{ data.first_name || '—' }}</template>
|
||||
<template v-else-if="col.field === 'last_name'">{{ data.last_name || '—' }}</template>
|
||||
<template v-else-if="col.field === 'language_code'">
|
||||
<span v-if="data.language_code">
|
||||
<i class="fa fa-globe"></i> {{ data.language_code.toUpperCase() }}
|
||||
</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'is_premium'">
|
||||
<i v-if="data.is_premium" class="fa fa-star" v-tooltip.top="'Премиум пользователь'"></i>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'oc_customer_id'">
|
||||
<span v-if="data.oc_customer_id">{{ data.oc_customer_id }}</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'last_seen_at'">
|
||||
<span v-if="data.last_seen_at">
|
||||
<i class="fa fa-clock-o"></i> {{ formatDate(data.last_seen_at) }}
|
||||
</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'created_at'">
|
||||
<i class="fa fa-calendar"></i> {{ formatDate(data.created_at) }}
|
||||
</template>
|
||||
</template>
|
||||
<template #filter="{ filterModel }">
|
||||
<template v-if="col.field === 'telegram_user_id'">
|
||||
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по ID" class="p-column-filter" />
|
||||
</template>
|
||||
<template v-else-if="col.field === 'username'">
|
||||
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по имени пользователя" class="p-column-filter" />
|
||||
</template>
|
||||
<template v-else-if="col.field === 'first_name'">
|
||||
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по имени" class="p-column-filter" />
|
||||
</template>
|
||||
<template v-else-if="col.field === 'last_name'">
|
||||
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по фамилии" class="p-column-filter" />
|
||||
</template>
|
||||
<template v-else-if="['last_seen_at', 'created_at'].includes(col.field)">
|
||||
<DatePicker v-model="filterModel.value" dateFormat="dd.mm.yy" placeholder="dd.mm.yyyy" />
|
||||
</template>
|
||||
<template v-else-if="col.field === 'is_premium'">
|
||||
<Dropdown
|
||||
v-model="filterModel.value"
|
||||
:options="premiumFilterOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Любой"
|
||||
class="p-column-filter"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div style="text-align: center; padding: 2rem;">
|
||||
<i class="fa fa-users" style="font-size: 3rem; color: #ccc; margin-bottom: 1rem;"></i>
|
||||
<div>Нет данных о кастомерах</div>
|
||||
<div style="font-size: 0.9rem; color: #999; margin-top: 0.5rem;">
|
||||
Пользователи появятся здесь после первого входа в Telegram Mini App
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #loading>
|
||||
<div style="text-align: center; padding: 2rem;">
|
||||
<i class="fa fa-spinner fa-spin" style="font-size: 2rem;"></i>
|
||||
<div style="margin-top: 1rem;">Загрузка данных...</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="showMessageDialog"
|
||||
modal
|
||||
header="Отправить сообщение"
|
||||
:style="{ width: '500px' }"
|
||||
:closable="true"
|
||||
>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<div style="margin-bottom: 0.5rem; font-weight: 600;">
|
||||
Получатель:
|
||||
</div>
|
||||
<div v-if="selectedCustomer">
|
||||
<div v-if="selectedCustomer.username">
|
||||
<i class="fa fa-user"></i> @{{ selectedCustomer.username }}
|
||||
</div>
|
||||
<div v-if="selectedCustomer.first_name || selectedCustomer.last_name">
|
||||
{{ selectedCustomer.first_name }} {{ selectedCustomer.last_name }}
|
||||
</div>
|
||||
<div style="font-size: 0.9rem; color: #666;">
|
||||
ID: {{ selectedCustomer.telegram_user_id }}
|
||||
</div>
|
||||
<div v-if="!selectedCustomer.allows_write_to_pm" style="color: #f59e0b; margin-top: 0.5rem;">
|
||||
<i class="fa fa-exclamation-triangle"></i> Пользователь не разрешил писать ему в PM
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="message" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
|
||||
Сообщение:
|
||||
</label>
|
||||
<Textarea
|
||||
id="message"
|
||||
v-model="messageText"
|
||||
:rows="5"
|
||||
:disabled="!selectedCustomer || !selectedCustomer.allows_write_to_pm"
|
||||
placeholder="Введите текст сообщения..."
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
label="Отмена"
|
||||
icon="fa fa-times"
|
||||
severity="secondary"
|
||||
@click="closeMessageDialog"
|
||||
:disabled="sendingMessage"
|
||||
/>
|
||||
<Button
|
||||
label="Отправить"
|
||||
icon="fa fa-paper-plane"
|
||||
@click="sendMessage"
|
||||
:loading="sendingMessage"
|
||||
:disabled="!messageText || !selectedCustomer || !selectedCustomer.allows_write_to_pm"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { FilterMatchMode, FilterOperator } from '@primevue/core/api';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import DatePicker from 'primevue/datepicker';
|
||||
import Dropdown from 'primevue/dropdown';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import OverlayPanel from 'primevue/overlaypanel';
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import Button from 'primevue/button';
|
||||
import { apiPost } from '@/utils/http.js';
|
||||
import { useToast, IconField, InputIcon } from 'primevue';
|
||||
|
||||
const toast = useToast();
|
||||
const customers = ref([]);
|
||||
const loading = ref(false);
|
||||
const totalRecords = ref(0);
|
||||
const showMessageDialog = ref(false);
|
||||
const selectedCustomer = ref(null);
|
||||
const messageText = ref('');
|
||||
const sendingMessage = ref(false);
|
||||
|
||||
const columns = ref([
|
||||
{ field: 'id', header: '№', sortable: true, filterable: false, visible: true },
|
||||
{ field: 'telegram_user_id', header: 'ID в Telegram', sortable: true, filterable: true, visible: false },
|
||||
{ field: 'username', header: 'Имя пользователя', sortable: true, filterable: true, visible: true },
|
||||
{ field: 'first_name', header: 'Имя', sortable: true, filterable: true, visible: true },
|
||||
{ field: 'last_name', header: 'Фамилия', sortable: true, filterable: true, visible: true },
|
||||
{ field: 'language_code', header: 'Язык интерфейса', sortable: true, filterable: false, visible: false },
|
||||
{ field: 'is_premium', header: 'Премиум статус', sortable: true, filterable: true, visible: true },
|
||||
{ field: 'oc_customer_id', header: 'ID покупателя', sortable: true, filterable: false, visible: false },
|
||||
{ field: 'last_seen_at', header: 'Последний визит', sortable: true, dataType: 'date', filterable: true, visible: true },
|
||||
{ field: 'created_at', header: 'Дата регистрации', sortable: true, dataType: 'date', filterable: true, visible: true },
|
||||
]);
|
||||
|
||||
const selectedColumns = ref(columns.value.filter(col => col.visible !== false));
|
||||
const columnsPanel = ref(null);
|
||||
const globalSearchValue = ref('');
|
||||
let searchTimeout = null;
|
||||
|
||||
function toggleColumnsPanel(event) {
|
||||
columnsPanel.value.toggle(event);
|
||||
}
|
||||
|
||||
function toggleColumn(col, checked) {
|
||||
// Сохраняем порядок колонок из исходного массива columns
|
||||
const selectedFields = new Set(selectedColumns.value.map(c => c.field));
|
||||
|
||||
if (checked) {
|
||||
selectedFields.add(col.field);
|
||||
} else {
|
||||
selectedFields.delete(col.field);
|
||||
}
|
||||
|
||||
// Пересоздаем массив, сохраняя исходный порядок из columns
|
||||
selectedColumns.value = columns.value.filter(c => selectedFields.has(c.field));
|
||||
}
|
||||
|
||||
function selectAllColumns() {
|
||||
selectedColumns.value = [...columns.value];
|
||||
}
|
||||
|
||||
function deselectAllColumns() {
|
||||
selectedColumns.value = [];
|
||||
}
|
||||
|
||||
const premiumFilterOptions = [
|
||||
{ label: 'Любой', value: null },
|
||||
{ label: 'Нет', value: false },
|
||||
{ label: 'Да', value: true },
|
||||
];
|
||||
|
||||
const lazyParams = ref({
|
||||
first: 0,
|
||||
rows: 20,
|
||||
page: 1,
|
||||
sortField: 'last_seen_at',
|
||||
sortOrder: -1,
|
||||
filters: {},
|
||||
});
|
||||
|
||||
const filters = ref({
|
||||
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
telegram_user_id: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.STARTS_WITH }] },
|
||||
username: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.CONTAINS }] },
|
||||
first_name: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.CONTAINS }] },
|
||||
last_name: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.CONTAINS }] },
|
||||
is_premium: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.EQUALS }] },
|
||||
created_at: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.DATE_IS }] },
|
||||
last_seen_at: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.DATE_IS }] },
|
||||
});
|
||||
|
||||
function processFiltersForBackend(filtersObj) {
|
||||
const processed = JSON.parse(JSON.stringify(filtersObj));
|
||||
|
||||
// Обрабатываем фильтры по датам
|
||||
const dateFields = ['created_at', 'last_seen_at'];
|
||||
dateFields.forEach(field => {
|
||||
if (processed[field] && processed[field].constraints) {
|
||||
processed[field].constraints.forEach(constraint => {
|
||||
if (constraint.value && ['dateIs', 'dateIsNot', 'dateBefore', 'dateAfter'].includes(constraint.matchMode)) {
|
||||
// Преобразуем дату в формат YYYY-MM-DD, используя локальное время
|
||||
const date = new Date(constraint.value);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
constraint.value = `${year}-${month}-${day}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
async function loadCustomers(event = null) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const processedFilters = processFiltersForBackend(filters.value);
|
||||
|
||||
const params = {
|
||||
page: lazyParams.value.page,
|
||||
rows: lazyParams.value.rows,
|
||||
sortField: lazyParams.value.sortField,
|
||||
sortOrder: lazyParams.value.sortOrder === -1 ? 'DESC' : 'ASC',
|
||||
filters: processedFilters,
|
||||
};
|
||||
|
||||
const result = await apiPost('getTelegramCustomers', params);
|
||||
if (result.success && result.data) {
|
||||
// apiPost возвращает полный ответ сервера, а apiGet возвращает response.data.data
|
||||
// Поэтому здесь нужно проверить, есть ли вложенный data
|
||||
const responseData = result.data.data ? result.data.data : result.data;
|
||||
customers.value = Array.isArray(responseData) ? responseData : (responseData.data || []);
|
||||
totalRecords.value = responseData.totalRecords || 0;
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: result.error || 'Не удалось загрузить данные',
|
||||
life: 3000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке кастомеров:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: 'Произошла ошибка при загрузке данных',
|
||||
life: 3000,
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onPage(event) {
|
||||
lazyParams.value.first = event.first;
|
||||
lazyParams.value.rows = event.rows;
|
||||
lazyParams.value.page = event.page + 1;
|
||||
loadCustomers(event);
|
||||
}
|
||||
|
||||
function onSort(event) {
|
||||
lazyParams.value.sortField = event.sortField;
|
||||
lazyParams.value.sortOrder = event.sortOrder;
|
||||
lazyParams.value.page = 1;
|
||||
lazyParams.value.first = 0;
|
||||
loadCustomers(event);
|
||||
}
|
||||
|
||||
function onFilter(event) {
|
||||
filters.value = event.filters;
|
||||
lazyParams.value.page = 1;
|
||||
lazyParams.value.first = 0;
|
||||
loadCustomers(event);
|
||||
}
|
||||
|
||||
function onGlobalSearch() {
|
||||
// Обновляем глобальный фильтр
|
||||
filters.value.global.value = globalSearchValue.value || null;
|
||||
|
||||
// Сбрасываем на первую страницу
|
||||
lazyParams.value.page = 1;
|
||||
lazyParams.value.first = 0;
|
||||
|
||||
// Debounce: очищаем предыдущий таймер
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
|
||||
// Устанавливаем новый таймер для отправки запроса через 300ms после последнего ввода
|
||||
searchTimeout = setTimeout(() => {
|
||||
loadCustomers();
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
globalSearchValue.value = '';
|
||||
filters.value = {
|
||||
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
telegram_user_id: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.STARTS_WITH }] },
|
||||
username: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.CONTAINS }] },
|
||||
first_name: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.CONTAINS }] },
|
||||
last_name: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.CONTAINS }] },
|
||||
is_premium: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.EQUALS }] },
|
||||
created_at: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.DATE_IS }] },
|
||||
last_seen_at: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.DATE_IS }] },
|
||||
};
|
||||
lazyParams.value.page = 1;
|
||||
lazyParams.value.first = 0;
|
||||
loadCustomers();
|
||||
}
|
||||
|
||||
function handleImageError(event) {
|
||||
// Скрываем изображение при ошибке загрузки
|
||||
event.target.style.display = 'none';
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '—';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function openMessageDialog(customer) {
|
||||
selectedCustomer.value = customer;
|
||||
messageText.value = '';
|
||||
showMessageDialog.value = true;
|
||||
}
|
||||
|
||||
function closeMessageDialog() {
|
||||
showMessageDialog.value = false;
|
||||
selectedCustomer.value = null;
|
||||
messageText.value = '';
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (!selectedCustomer.value || !messageText.value.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedCustomer.value.allows_write_to_pm) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Предупреждение',
|
||||
detail: 'Пользователь не разрешил писать ему в PM',
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendingMessage.value = true;
|
||||
try {
|
||||
const result = await apiPost('sendMessageToCustomer', {
|
||||
id: selectedCustomer.value.id,
|
||||
message: messageText.value.trim(),
|
||||
});
|
||||
|
||||
if (result.success && result.data?.success) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Успешно',
|
||||
detail: result.data?.message || 'Сообщение отправлено',
|
||||
life: 3000,
|
||||
});
|
||||
closeMessageDialog();
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: result.data?.error || result.error || 'Не удалось отправить сообщение',
|
||||
life: 3000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при отправке сообщения:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: 'Произошла ошибка при отправке сообщения',
|
||||
life: 3000,
|
||||
});
|
||||
} finally {
|
||||
sendingMessage.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCustomers();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import ApplicationError from "@/ApplicationError.vue";
|
||||
import AppMetaInitializer from "@/utils/AppMetaInitializer.ts";
|
||||
import {injectYaMetrika} from "@/utils/yaMetrika.js";
|
||||
import {saveTelegramCustomer} from "@/utils/ftch.js";
|
||||
|
||||
import {register} from 'swiper/element/bundle';
|
||||
import 'swiper/element/bundle';
|
||||
@@ -44,6 +45,20 @@ settings.load()
|
||||
throw new Error('App disabled (maintenance mode)');
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
// Сохраняем данные Telegram-пользователя в базу данных
|
||||
const userData = window.Telegram?.WebApp?.initDataUnsafe?.user;
|
||||
if (userData) {
|
||||
try {
|
||||
console.debug('[Init] Saving Telegram customer data');
|
||||
await saveTelegramCustomer(userData);
|
||||
console.debug('[Init] Telegram customer data saved successfully');
|
||||
} catch (error) {
|
||||
// Не прерываем загрузку приложения, если не удалось сохранить пользователя
|
||||
console.warn('[Init] Failed to save Telegram customer data:', error);
|
||||
}
|
||||
}
|
||||
})
|
||||
.then(() => blocks.processBlocks(settings.mainpage_blocks))
|
||||
.then(async () => {
|
||||
console.debug('Load default filters for the main page');
|
||||
|
||||
@@ -96,4 +96,15 @@ export async function processBlock(block) {
|
||||
return await ftch('processBlock', null, block);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить или обновить данные Telegram-пользователя
|
||||
* @param {Object} userData - Данные пользователя из Telegram.WebApp.initDataUnsafe.user
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function saveTelegramCustomer(userData) {
|
||||
return await ftch('saveTelegramCustomer', null, {
|
||||
user: userData,
|
||||
});
|
||||
}
|
||||
|
||||
export default ftch;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Bastion\Handlers;
|
||||
|
||||
use App\Exceptions\TelegramCustomerNotFoundException;
|
||||
use App\Exceptions\TelegramCustomerWriteNotAllowedException;
|
||||
use App\Models\TelegramCustomer;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Openguru\OpenCartFramework\Http\JsonResponse;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Openguru\OpenCartFramework\Telegram\TelegramService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Handler для отправки сообщений Telegram-пользователям из админ-панели
|
||||
*
|
||||
* @package Bastion\Handlers
|
||||
*/
|
||||
class SendMessageHandler
|
||||
{
|
||||
private TelegramService $telegramService;
|
||||
private TelegramCustomer $telegramCustomerModel;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(
|
||||
TelegramService $telegramService,
|
||||
TelegramCustomer $telegramCustomerModel,
|
||||
LoggerInterface $logger
|
||||
) {
|
||||
$this->telegramService = $telegramService;
|
||||
$this->telegramCustomerModel = $telegramCustomerModel;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить сообщение Telegram-пользователю
|
||||
*
|
||||
* @param Request $request HTTP запрос с id (ID записи в таблице) и message
|
||||
* @return JsonResponse JSON ответ с результатом операции
|
||||
* @throws TelegramCustomerNotFoundException Если пользователь не найден
|
||||
* @throws TelegramCustomerWriteNotAllowedException Если пользователь не разрешил писать в PM
|
||||
* @throws RuntimeException Если данные невалидны
|
||||
* @throws \Exception
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function sendMessage(Request $request): JsonResponse
|
||||
{
|
||||
$customerId = $this->extractCustomerId($request);
|
||||
$message = $this->extractMessage($request);
|
||||
|
||||
// Находим запись по ID
|
||||
$customer = $this->telegramCustomerModel->findById($customerId);
|
||||
if (! $customer) {
|
||||
throw new TelegramCustomerNotFoundException($customerId);
|
||||
}
|
||||
|
||||
$telegramUserId = (int) $customer['telegram_user_id'];
|
||||
|
||||
// Проверяем, что пользователь разрешил писать ему в PM
|
||||
if (! $customer['allows_write_to_pm']) {
|
||||
throw new TelegramCustomerWriteNotAllowedException($telegramUserId);
|
||||
}
|
||||
|
||||
// Отправляем сообщение (telegram_user_id используется как chat_id)
|
||||
// Используем пустую строку для parse_mode чтобы отправлять обычный текст
|
||||
$this->telegramService->sendMessage(
|
||||
$telegramUserId,
|
||||
$message,
|
||||
[],
|
||||
\Openguru\OpenCartFramework\Telegram\Enums\ChatAction::TYPING,
|
||||
'' // Обычный текст без форматирования
|
||||
);
|
||||
|
||||
$this->logger->info('Message sent to Telegram user', [
|
||||
'oc_customer_id' => $customerId,
|
||||
'telegram_user_id' => $telegramUserId,
|
||||
'message_length' => strlen($message),
|
||||
]);
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Message sent successfully',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлечь ID записи из запроса
|
||||
*
|
||||
* @param Request $request HTTP запрос
|
||||
* @return int ID записи в таблице telecart_customers
|
||||
* @throws RuntimeException Если ID отсутствует или невалиден
|
||||
*/
|
||||
private function extractCustomerId(Request $request): int
|
||||
{
|
||||
$jsonData = $request->json();
|
||||
$customerId = isset($jsonData['id']) ? (int) $jsonData['id'] : 0;
|
||||
|
||||
if ($customerId <= 0) {
|
||||
throw new RuntimeException('Customer ID is required and must be positive');
|
||||
}
|
||||
|
||||
return $customerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлечь сообщение из запроса
|
||||
*
|
||||
* @param Request $request HTTP запрос
|
||||
* @return string Текст сообщения
|
||||
* @throws RuntimeException Если сообщение отсутствует или пустое
|
||||
*/
|
||||
private function extractMessage(Request $request): string
|
||||
{
|
||||
$jsonData = $request->json();
|
||||
$message = isset($jsonData['message']) ? trim($jsonData['message']) : '';
|
||||
|
||||
if (empty($message)) {
|
||||
throw new RuntimeException('Message is required and cannot be empty');
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Bastion\Handlers;
|
||||
|
||||
use Openguru\OpenCartFramework\Http\JsonResponse;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
|
||||
class TelegramCustomersHandler
|
||||
{
|
||||
private const TABLE_NAME = 'telecart_customers';
|
||||
private const DEFAULT_PAGE = 1;
|
||||
private const DEFAULT_ROWS = 20;
|
||||
private const DEFAULT_SORT_FIELD = 'last_seen_at';
|
||||
private const DEFAULT_SORT_ORDER = 'DESC';
|
||||
|
||||
private Builder $builder;
|
||||
|
||||
public function __construct(Builder $builder)
|
||||
{
|
||||
$this->builder = $builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список Telegram-кастомеров с пагинацией, фильтрацией и сортировкой
|
||||
*
|
||||
* @param Request $request HTTP запрос с параметрами пагинации, сортировки и фильтров
|
||||
* @return JsonResponse JSON ответ с данными и метаинформацией
|
||||
*/
|
||||
public function getCustomers(Request $request): JsonResponse
|
||||
{
|
||||
$page = max(1, (int) $request->json('page', self::DEFAULT_PAGE));
|
||||
$rows = max(1, (int) $request->json('rows', self::DEFAULT_ROWS));
|
||||
$first = ($page - 1) * $rows;
|
||||
|
||||
$sortField = $request->json('sortField', self::DEFAULT_SORT_FIELD) ?? self::DEFAULT_SORT_FIELD;
|
||||
$sortOrder = $this->normalizeSortOrder((string)$request->json('sortOrder', self::DEFAULT_SORT_ORDER));
|
||||
|
||||
$filters = $request->json('filters', []);
|
||||
$globalFilter = Arr::get($filters, 'global.value');
|
||||
|
||||
// Создаем базовый query с фильтрами
|
||||
$query = $this->buildBaseQuery();
|
||||
$this->applyFilters($query, $filters, $globalFilter);
|
||||
|
||||
// Получаем общее количество записей
|
||||
$countQuery = $this->buildCountQuery();
|
||||
$this->applyFilters($countQuery, $filters, $globalFilter);
|
||||
$totalRecords = (int) ($countQuery->value('total') ?? 0);
|
||||
|
||||
// Применяем сортировку и пагинацию
|
||||
$customers = $query
|
||||
->orderBy($sortField, $sortOrder)
|
||||
->offset($first)
|
||||
->limit($rows)
|
||||
->get();
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => [
|
||||
'data' => $this->mapToResponse($customers),
|
||||
'totalRecords' => $totalRecords,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать базовый query для выборки данных
|
||||
*
|
||||
* @return Builder
|
||||
*/
|
||||
private function buildBaseQuery(): Builder
|
||||
{
|
||||
return $this->builder->newQuery()
|
||||
->select([
|
||||
'id',
|
||||
'telegram_user_id',
|
||||
'oc_customer_id',
|
||||
'username',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'language_code',
|
||||
'is_premium',
|
||||
'allows_write_to_pm',
|
||||
'photo_url',
|
||||
'last_seen_at',
|
||||
'referral',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])
|
||||
->from(self::TABLE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать query для подсчета общего количества записей
|
||||
*
|
||||
* @return Builder
|
||||
*/
|
||||
private function buildCountQuery(): Builder
|
||||
{
|
||||
return $this->builder->newQuery()
|
||||
->select([new RawExpression('COUNT(*) as total')])
|
||||
->from(self::TABLE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Применить фильтры к query
|
||||
*
|
||||
* @param Builder $query Query builder
|
||||
* @param array $filters Массив фильтров
|
||||
* @param string|null $globalFilter Глобальный фильтр поиска
|
||||
* @return void
|
||||
*/
|
||||
private function applyFilters(Builder $query, array $filters, ?string $globalFilter): void
|
||||
{
|
||||
// Применяем глобальный фильтр
|
||||
if ($globalFilter) {
|
||||
$this->applyGlobalFilter($query, $globalFilter);
|
||||
}
|
||||
|
||||
// Применяем фильтры по колонкам
|
||||
$this->applyColumnFilters($query, $filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Применить глобальный фильтр поиска
|
||||
*
|
||||
* @param Builder $query Query builder
|
||||
* @param string $searchTerm Поисковый запрос
|
||||
* @return void
|
||||
*/
|
||||
private function applyGlobalFilter(Builder $query, string $searchTerm): void
|
||||
{
|
||||
$query->whereNested(function ($q) use ($searchTerm) {
|
||||
$q->where('telegram_user_id', 'LIKE', "%{$searchTerm}%")
|
||||
->orWhere('username', 'LIKE', "%{$searchTerm}%")
|
||||
->orWhere('first_name', 'LIKE', "%{$searchTerm}%")
|
||||
->orWhere('last_name', 'LIKE', "%{$searchTerm}%")
|
||||
->orWhere('language_code', 'LIKE', "%{$searchTerm}%");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Применить фильтры по колонкам
|
||||
*
|
||||
* @param Builder $query Query builder
|
||||
* @param array $filters Массив фильтров
|
||||
* @return void
|
||||
*/
|
||||
private function applyColumnFilters(Builder $query, array $filters): void
|
||||
{
|
||||
foreach ($filters as $field => $filter) {
|
||||
if ($field === 'global') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Обработка сложных фильтров (constraints)
|
||||
if (isset($filter['constraints']) && is_array($filter['constraints'])) {
|
||||
$this->applyConstraintFilters($query, $field, $filter);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Обработка простых фильтров (обратная совместимость)
|
||||
if (! isset($filter['value']) || $filter['value'] === null || $filter['value'] === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $filter['value'];
|
||||
$matchMode = Arr::get($filter, 'matchMode', 'contains');
|
||||
|
||||
$this->applyColumnFilter($query, $field, $value, $matchMode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Применить сложные фильтры с условиями (AND/OR)
|
||||
*
|
||||
* @param Builder $query Query builder
|
||||
* @param string $field Имя поля
|
||||
* @param array $filter Данные фильтра
|
||||
* @return void
|
||||
*/
|
||||
private function applyConstraintFilters(Builder $query, string $field, array $filter): void
|
||||
{
|
||||
$operator = strtolower($filter['operator'] ?? 'and');
|
||||
$constraints = $filter['constraints'];
|
||||
|
||||
// Фильтруем пустые значения (но учитываем false как валидное значение для boolean полей)
|
||||
$activeConstraints = array_filter($constraints, function ($constraint) {
|
||||
if (!isset($constraint['value'])) {
|
||||
return false;
|
||||
}
|
||||
$value = $constraint['value'];
|
||||
// null означает "любой", пропускаем
|
||||
if ($value === null) {
|
||||
return false;
|
||||
}
|
||||
// Пустая строка пропускаем
|
||||
if ($value === '') {
|
||||
return false;
|
||||
}
|
||||
// false - валидное значение для boolean полей
|
||||
return true;
|
||||
});
|
||||
|
||||
if (empty($activeConstraints)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query->whereNested(function ($q) use ($field, $activeConstraints, $operator) {
|
||||
// Для первого элемента всегда используем where, чтобы начать группу
|
||||
$first = true;
|
||||
|
||||
foreach ($activeConstraints as $constraint) {
|
||||
$value = $constraint['value'];
|
||||
$matchMode = $constraint['matchMode'] ?? 'contains';
|
||||
|
||||
if ($first) {
|
||||
$this->applyColumnFilter($q, $field, $value, $matchMode);
|
||||
$first = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($operator === 'or') {
|
||||
$q->orWhere(function ($subQ) use ($field, $value, $matchMode) {
|
||||
$this->applyColumnFilter($subQ, $field, $value, $matchMode);
|
||||
});
|
||||
} else {
|
||||
$this->applyColumnFilter($q, $field, $value, $matchMode);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Применить фильтр для одной колонки
|
||||
*
|
||||
* @param Builder $query Query builder
|
||||
* @param string $field Имя поля
|
||||
* @param mixed $value Значение фильтра
|
||||
* @param string $matchMode Режим совпадения (contains, startsWith, endsWith, equals, notEquals)
|
||||
* @return void
|
||||
*/
|
||||
private function applyColumnFilter(Builder $query, string $field, $value, string $matchMode): void
|
||||
{
|
||||
if (in_array($matchMode, ['contains', 'startsWith', 'endsWith'], true)) {
|
||||
$likeValue = $this->buildLikeValue($value, $matchMode);
|
||||
$query->where($field, 'LIKE', $likeValue);
|
||||
} elseif ($matchMode === 'equals') {
|
||||
$query->where($field, '=', $value);
|
||||
} elseif ($matchMode === 'notEquals') {
|
||||
$query->where($field, '!=', $value);
|
||||
} elseif ($matchMode === 'dateIs') {
|
||||
// Для точного совпадения даты используем диапазон от 00:00:00 до 23:59:59
|
||||
$date = date('Y-m-d', strtotime($value));
|
||||
$query->where($field, '>=', $date . ' 00:00:00')
|
||||
->where($field, '<=', $date . ' 23:59:59');
|
||||
} elseif ($matchMode === 'dateIsNot') {
|
||||
// Для отрицания проверяем, что дата меньше начала дня ИЛИ больше конца дня
|
||||
$date = date('Y-m-d', strtotime($value));
|
||||
$query->whereNested(function ($q) use ($field, $date) {
|
||||
$q->where($field, '<', $date . ' 00:00:00')
|
||||
->orWhere($field, '>', $date . ' 23:59:59');
|
||||
});
|
||||
} elseif ($matchMode === 'dateBefore') {
|
||||
$query->where($field, '<', date('Y-m-d 00:00:00', strtotime($value)));
|
||||
} elseif ($matchMode === 'dateAfter') {
|
||||
// "После" означает после конца указанного дня
|
||||
$query->where($field, '>', date('Y-m-d 23:59:59', strtotime($value)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Построить значение для LIKE запроса
|
||||
*
|
||||
* @param string $value Значение
|
||||
* @param string $matchMode Режим совпадения
|
||||
* @return string
|
||||
*/
|
||||
private function buildLikeValue(string $value, string $matchMode): string
|
||||
{
|
||||
if ($matchMode === 'startsWith') {
|
||||
return "{$value}%";
|
||||
}
|
||||
|
||||
if ($matchMode === 'endsWith') {
|
||||
return "%{$value}";
|
||||
}
|
||||
|
||||
return "%{$value}%";
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализовать порядок сортировки
|
||||
*
|
||||
* @param string $sortOrder Порядок сортировки
|
||||
* @return string 'ASC' или 'DESC'
|
||||
*/
|
||||
private function normalizeSortOrder(string $sortOrder): string
|
||||
{
|
||||
$normalized = strtoupper($sortOrder);
|
||||
|
||||
return in_array($normalized, ['ASC', 'DESC'], true) ? $normalized : self::DEFAULT_SORT_ORDER;
|
||||
}
|
||||
|
||||
private function mapToResponse(array $customers): array
|
||||
{
|
||||
return array_map(static function (array $customer) {
|
||||
return [
|
||||
'id' => (int) $customer['id'],
|
||||
'telegram_user_id' => (int) $customer['telegram_user_id'],
|
||||
'oc_customer_id' => (int) $customer['oc_customer_id'],
|
||||
'username' => $customer['username'],
|
||||
'first_name' => $customer['first_name'],
|
||||
'last_name' => $customer['last_name'],
|
||||
'language_code' => $customer['language_code'],
|
||||
'is_premium' => filter_var($customer['is_premium'], FILTER_VALIDATE_BOOLEAN),
|
||||
'allows_write_to_pm' => filter_var($customer['allows_write_to_pm'], FILTER_VALIDATE_BOOLEAN),
|
||||
'photo_url' => $customer['photo_url'],
|
||||
'last_seen_at' => $customer['last_seen_at'],
|
||||
'referral' => $customer['referral'],
|
||||
'created_at' => $customer['created_at'],
|
||||
'updated_at' => $customer['updated_at'],
|
||||
];
|
||||
}, $customers);
|
||||
}
|
||||
}
|
||||
@@ -4,27 +4,28 @@ use Bastion\Handlers\AutocompleteHandler;
|
||||
use Bastion\Handlers\DictionariesHandler;
|
||||
use Bastion\Handlers\FormsHandler;
|
||||
use Bastion\Handlers\LogsHandler;
|
||||
use Bastion\Handlers\SendMessageHandler;
|
||||
use Bastion\Handlers\SettingsHandler;
|
||||
use Bastion\Handlers\StatsHandler;
|
||||
use Bastion\Handlers\TelegramCustomersHandler;
|
||||
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'],
|
||||
|
||||
'getCategories' => [DictionariesHandler::class, 'getCategories'],
|
||||
|
||||
'getAutocompleteCategories' => [AutocompleteHandler::class, 'getCategories'],
|
||||
'getAutocompleteCategoriesFlat' => [AutocompleteHandler::class, 'getCategoriesFlat'],
|
||||
'resetCache' => [SettingsHandler::class, 'resetCache'],
|
||||
'getLogs' => [LogsHandler::class, 'getLogs'],
|
||||
|
||||
'getCategories' => [DictionariesHandler::class, 'getCategories'],
|
||||
'getCategoriesById' => [AutocompleteHandler::class, 'getCategoriesById'],
|
||||
'getChatId' => [TelegramHandler::class, 'getChatId'],
|
||||
'getDashboardStats' => [StatsHandler::class, 'getDashboardStats'],
|
||||
'getFormByAlias' => [FormsHandler::class, 'getFormByAlias'],
|
||||
'getLogs' => [LogsHandler::class, 'getLogs'],
|
||||
'getProductsById' => [AutocompleteHandler::class, 'getProductsById'],
|
||||
'getSettingsForm' => [SettingsHandler::class, 'getSettingsForm'],
|
||||
'getTelegramCustomers' => [TelegramCustomersHandler::class, 'getCustomers'],
|
||||
'resetCache' => [SettingsHandler::class, 'resetCache'],
|
||||
'saveSettingsForm' => [SettingsHandler::class, 'saveSettingsForm'],
|
||||
'sendMessageToCustomer' => [SendMessageHandler::class, 'sendMessage'],
|
||||
'testTgMessage' => [TelegramHandler::class, 'testTgMessage'],
|
||||
'tgGetMe' => [TelegramHandler::class, 'tgGetMe'],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Openguru\OpenCartFramework\Migrations\Migration;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$tableName = 'telecart_customers';
|
||||
|
||||
$sql = <<<SQL
|
||||
CREATE TABLE IF NOT EXISTS `{$tableName}` (
|
||||
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`telegram_user_id` BIGINT(20) UNSIGNED NOT NULL,
|
||||
`oc_customer_id` INT(11) UNSIGNED DEFAULT NULL,
|
||||
`username` VARCHAR(255) DEFAULT NULL,
|
||||
`first_name` VARCHAR(255) DEFAULT NULL,
|
||||
`last_name` VARCHAR(255) DEFAULT NULL,
|
||||
`language_code` VARCHAR(10) DEFAULT NULL,
|
||||
`is_premium` TINYINT(1) UNSIGNED DEFAULT 0,
|
||||
`allows_write_to_pm` TINYINT(1) UNSIGNED DEFAULT 0,
|
||||
`photo_url` VARCHAR(512) DEFAULT NULL,
|
||||
`last_seen_at` DATETIME DEFAULT NULL,
|
||||
`referral` VARCHAR(255) DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `unique_telegram_user_id` (`telegram_user_id`),
|
||||
KEY `idx_oc_customer_id` (`oc_customer_id`),
|
||||
KEY `idx_last_seen_at` (`last_seen_at`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB;
|
||||
SQL;
|
||||
|
||||
$this->database->statement($sql);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace Openguru\OpenCartFramework\Telegram\Enums;
|
||||
|
||||
final class TelegramHeader
|
||||
{
|
||||
public const INIT_DATA = 'X-Telegram-Initdata';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace Openguru\OpenCartFramework\Telegram\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class DecodeTelegramInitDataException extends Exception
|
||||
{
|
||||
}
|
||||
@@ -4,22 +4,33 @@ namespace Openguru\OpenCartFramework\Telegram;
|
||||
|
||||
use App\Services\SettingsService;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Openguru\OpenCartFramework\Telegram\Enums\TelegramHeader;
|
||||
use Openguru\OpenCartFramework\Telegram\Exceptions\DecodeTelegramInitDataException;
|
||||
use Openguru\OpenCartFramework\Telegram\Exceptions\TelegramInvalidSignatureException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class SignatureValidator
|
||||
{
|
||||
private ?string $botToken;
|
||||
private SettingsService $settings;
|
||||
private LoggerInterface $logger;
|
||||
private TelegramInitDataDecoder $initDataDecoder;
|
||||
|
||||
public function __construct(SettingsService $settings, LoggerInterface $logger, ?string $botToken = null)
|
||||
{
|
||||
public function __construct(
|
||||
SettingsService $settings,
|
||||
LoggerInterface $logger,
|
||||
TelegramInitDataDecoder $initDataDecoder,
|
||||
?string $botToken = null
|
||||
) {
|
||||
$this->botToken = $botToken;
|
||||
$this->settings = $settings;
|
||||
$this->logger = $logger;
|
||||
$this->initDataDecoder = $initDataDecoder;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TelegramInvalidSignatureException
|
||||
*/
|
||||
public function validate(Request $request): void
|
||||
{
|
||||
if ($this->settings->config()->getApp()->isAppDebug()) {
|
||||
@@ -32,13 +43,15 @@ class SignatureValidator
|
||||
return;
|
||||
}
|
||||
|
||||
$initDataString = base64_decode($request->header('X-Telegram-Initdata'));
|
||||
|
||||
if (! $initDataString) {
|
||||
throw new TelegramInvalidSignatureException('Invalid Telegram signature!');
|
||||
if (! $request->header(TelegramHeader::INIT_DATA)) {
|
||||
throw new TelegramInvalidSignatureException('Telegram Signature not exists.');
|
||||
}
|
||||
|
||||
$data = $this->parseInitDataStringToArray($initDataString);
|
||||
try {
|
||||
$data = $this->initDataDecoder->decode($request->header(TelegramHeader::INIT_DATA));
|
||||
} catch (DecodeTelegramInitDataException $e) {
|
||||
throw new TelegramInvalidSignatureException('Invalid Telegram Signature.', 500, $e);
|
||||
}
|
||||
|
||||
if (! isset($data['hash'])) {
|
||||
throw new TelegramInvalidSignatureException('Missing hash in init data');
|
||||
@@ -55,26 +68,6 @@ class SignatureValidator
|
||||
}
|
||||
}
|
||||
|
||||
private function parseInitDataStringToArray(string $initData): array
|
||||
{
|
||||
parse_str($initData, $parsed);
|
||||
|
||||
foreach ($parsed as $key => $value) {
|
||||
if ($this->isValidJson($value)) {
|
||||
$parsed[$key] = json_decode(urldecode($value), true);
|
||||
}
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private function isValidJson(string $jsonString): bool
|
||||
{
|
||||
json_decode($jsonString);
|
||||
|
||||
return (json_last_error() === JSON_ERROR_NONE);
|
||||
}
|
||||
|
||||
private function getCheckString(array $data): string
|
||||
{
|
||||
unset($data['hash']);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Openguru\OpenCartFramework\Telegram;
|
||||
|
||||
use JsonException;
|
||||
use Openguru\OpenCartFramework\Telegram\Exceptions\DecodeTelegramInitDataException;
|
||||
|
||||
class TelegramInitDataDecoder
|
||||
{
|
||||
/**
|
||||
* @throws DecodeTelegramInitDataException
|
||||
*/
|
||||
public function decode(string $initDataDecoded): array
|
||||
{
|
||||
$initDataString = base64_decode($initDataDecoded);
|
||||
|
||||
if ($initDataString === false) {
|
||||
throw new DecodeTelegramInitDataException('Could not decode init data.');
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->parseInitDataStringToArray($initDataString);
|
||||
} catch (JsonException $e) {
|
||||
throw new DecodeTelegramInitDataException(
|
||||
'Error decoding Telegram init data JSON: ' . $e->getMessage(),
|
||||
500,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws JsonException
|
||||
*/
|
||||
private function parseInitDataStringToArray(string $initData): array
|
||||
{
|
||||
parse_str($initData, $parsed);
|
||||
|
||||
foreach ($parsed as $key => $value) {
|
||||
if ($this->isValidJson($value)) {
|
||||
$parsed[$key] = json_decode(urldecode($value), true, 512, JSON_THROW_ON_ERROR);
|
||||
} else {
|
||||
$parsed[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private function isValidJson(string $string): bool
|
||||
{
|
||||
try {
|
||||
json_decode($string, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return true;
|
||||
} catch (JsonException $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ class TelegramServiceProvider extends ServiceProvider
|
||||
return new SignatureValidator(
|
||||
$app->get(SettingsService::class),
|
||||
$app->get(LoggerInterface::class),
|
||||
$app->get(TelegramInitDataDecoder::class),
|
||||
$app->getConfigValue('telegram.bot_token'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Исключение, выбрасываемое когда Telegram-кастомер не найден
|
||||
*
|
||||
* @package App\Exceptions
|
||||
*/
|
||||
class TelegramCustomerNotFoundException extends RuntimeException
|
||||
{
|
||||
public function __construct(int $customerId, ?\Throwable $previous = null)
|
||||
{
|
||||
parent::__construct(
|
||||
"Telegram customer with record ID {$customerId} not found",
|
||||
404,
|
||||
$previous
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Исключение, выбрасываемое когда пользователь не разрешил писать ему в PM
|
||||
*
|
||||
* @package App\Exceptions
|
||||
*/
|
||||
class TelegramCustomerWriteNotAllowedException extends RuntimeException
|
||||
{
|
||||
public function __construct(int $telegramUserId, ?\Throwable $previous = null)
|
||||
{
|
||||
parent::__construct(
|
||||
"User {$telegramUserId} has not allowed writing to PM",
|
||||
400,
|
||||
$previous
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Handlers;
|
||||
|
||||
use App\Services\TelegramCustomerService;
|
||||
use Openguru\OpenCartFramework\Http\JsonResponse;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Openguru\OpenCartFramework\Http\Response;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use Openguru\OpenCartFramework\Telegram\Enums\TelegramHeader;
|
||||
use Openguru\OpenCartFramework\Telegram\Exceptions\DecodeTelegramInitDataException;
|
||||
use Openguru\OpenCartFramework\Telegram\TelegramInitDataDecoder;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class TelegramCustomerHandler
|
||||
{
|
||||
private TelegramCustomerService $telegramCustomerService;
|
||||
private LoggerInterface $logger;
|
||||
private TelegramInitDataDecoder $initDataDecoder;
|
||||
|
||||
public function __construct(
|
||||
TelegramCustomerService $telegramCustomerService,
|
||||
LoggerInterface $logger,
|
||||
TelegramInitDataDecoder $initDataDecoder
|
||||
) {
|
||||
$this->telegramCustomerService = $telegramCustomerService;
|
||||
$this->logger = $logger;
|
||||
$this->initDataDecoder = $initDataDecoder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить или обновить Telegram-пользователя
|
||||
*
|
||||
* @param Request $request HTTP запрос с данными пользователя
|
||||
* @return JsonResponse JSON ответ с результатом операции
|
||||
*/
|
||||
public function saveOrUpdate(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->telegramCustomerService->saveOrUpdate(
|
||||
$this->extractTelegramUserData($request)
|
||||
);
|
||||
|
||||
return new JsonResponse([], Response::HTTP_NO_CONTENT);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('Could not save telegram customer data', ['exception' => $e]);
|
||||
|
||||
return new JsonResponse([], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлечь данные Telegram пользователя из запроса
|
||||
*
|
||||
* @param Request $request HTTP запрос
|
||||
* @return array Данные пользователя
|
||||
* @throws RuntimeException|DecodeTelegramInitDataException невозможно извлечь данные пользователя из Request
|
||||
*/
|
||||
private function extractTelegramUserData(Request $request): array
|
||||
{
|
||||
$telegramUserData = $request->json('user');
|
||||
|
||||
if (! $telegramUserData) {
|
||||
$telegramUserData = $this->extractUserDataFromInitData($request);
|
||||
}
|
||||
|
||||
return $telegramUserData;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DecodeTelegramInitDataException
|
||||
*/
|
||||
private function extractUserDataFromInitData(Request $request): array
|
||||
{
|
||||
$raw = $request->header(TelegramHeader::INIT_DATA);
|
||||
if (! $raw) {
|
||||
throw new RuntimeException('No init data found in http request header');
|
||||
}
|
||||
|
||||
$initData = $this->initDataDecoder->decode($raw);
|
||||
|
||||
return Arr::get($initData, 'user');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
|
||||
use RuntimeException;
|
||||
|
||||
class TelegramCustomer
|
||||
{
|
||||
private const TABLE_NAME = 'telecart_customers';
|
||||
|
||||
private ConnectionInterface $database;
|
||||
private Builder $builder;
|
||||
|
||||
public function __construct(ConnectionInterface $database, Builder $builder)
|
||||
{
|
||||
$this->database = $database;
|
||||
$this->builder = $builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Найти запись по ID
|
||||
*
|
||||
* @param int $id ID записи
|
||||
* @return array|null Данные пользователя или null если не найдено
|
||||
*/
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
return $this->builder
|
||||
->newQuery()
|
||||
->select(['*'])
|
||||
->from(self::TABLE_NAME)
|
||||
->where('id', '=', $id)
|
||||
->firstOrNull();
|
||||
}
|
||||
|
||||
/**
|
||||
* Найти запись по Telegram user ID
|
||||
*
|
||||
* @param int $telegramUserId Telegram user ID
|
||||
* @return array|null Данные пользователя или null если не найдено
|
||||
*/
|
||||
public function findByTelegramUserId(int $telegramUserId): ?array
|
||||
{
|
||||
return $this->builder
|
||||
->newQuery()
|
||||
->select(['*'])
|
||||
->from(self::TABLE_NAME)
|
||||
->where('telegram_user_id', '=', $telegramUserId)
|
||||
->firstOrNull();
|
||||
}
|
||||
|
||||
/**
|
||||
* Найти запись по oc_customer_id
|
||||
*
|
||||
* @param int $customerId ID покупателя в OpenCart
|
||||
* @return array|null Данные пользователя или null если не найдено
|
||||
*/
|
||||
public function findByCustomerId(int $customerId): ?array
|
||||
{
|
||||
return $this->builder
|
||||
->newQuery()
|
||||
->select(['*'])
|
||||
->from(self::TABLE_NAME)
|
||||
->where('oc_customer_id', '=', $customerId)
|
||||
->firstOrNull();
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать новую запись
|
||||
*
|
||||
* @param array $data Данные для создания записи
|
||||
* @return int ID созданной записи
|
||||
* @throws RuntimeException Если не удалось создать запись
|
||||
*/
|
||||
public function create(array $data): int
|
||||
{
|
||||
$data['created_at'] = date('Y-m-d H:i:s');
|
||||
$data['updated_at'] = date('Y-m-d H:i:s');
|
||||
|
||||
$success = $this->database->insert(self::TABLE_NAME, $data);
|
||||
|
||||
if (! $success) {
|
||||
$error = $this->database->getLastError();
|
||||
$errorMessage = $error ? $error[1] : 'Unknown error';
|
||||
throw new RuntimeException("Failed to insert telegram customer. Error: {$errorMessage}");
|
||||
}
|
||||
|
||||
return $this->database->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить запись по Telegram user ID
|
||||
*
|
||||
* @param int $telegramUserId Telegram user ID
|
||||
* @param array $data Данные для обновления
|
||||
* @return bool true если обновление успешно
|
||||
*/
|
||||
public function updateByTelegramUserId(int $telegramUserId, array $data): bool
|
||||
{
|
||||
$data['updated_at'] = date('Y-m-d H:i:s');
|
||||
|
||||
return $this->builder->newQuery()
|
||||
->where('telegram_user_id', '=', $telegramUserId)
|
||||
->update(self::TABLE_NAME, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить last_seen_at для пользователя
|
||||
*
|
||||
* @param int $telegramUserId Telegram user ID
|
||||
* @return bool true если обновление успешно
|
||||
*/
|
||||
public function updateLastSeen(int $telegramUserId): bool
|
||||
{
|
||||
return $this->updateByTelegramUserId($telegramUserId, [
|
||||
'last_seen_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\TelegramCustomer;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Сервис для работы с Telegram-кастомерами
|
||||
*
|
||||
* @package App\Services
|
||||
*/
|
||||
class TelegramCustomerService
|
||||
{
|
||||
private TelegramCustomer $telegramCustomerModel;
|
||||
|
||||
public function __construct(TelegramCustomer $telegramCustomerModel)
|
||||
{
|
||||
$this->telegramCustomerModel = $telegramCustomerModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить или обновить Telegram-пользователя
|
||||
*
|
||||
* @param array $telegramUserData Данные пользователя из Telegram.WebApp.initDataUnsafe
|
||||
* @return void
|
||||
* @throws RuntimeException Если данные невалидны или не удалось сохранить
|
||||
*/
|
||||
public function saveOrUpdate(array $telegramUserData): void
|
||||
{
|
||||
$telegramUserId = $this->extractTelegramUserId($telegramUserData);
|
||||
$telegramCustomerData = $this->prepareCustomerData($telegramUserData, $telegramUserId);
|
||||
|
||||
$existingRecord = $this->telegramCustomerModel->findByTelegramUserId($telegramUserId);
|
||||
|
||||
if ($existingRecord) {
|
||||
$this->telegramCustomerModel->updateByTelegramUserId($telegramUserId, $telegramCustomerData);
|
||||
} else {
|
||||
$this->telegramCustomerModel->create($telegramCustomerData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлечь Telegram user ID из данных
|
||||
*
|
||||
* @param array $telegramUserData Данные пользователя
|
||||
* @return int Telegram user ID
|
||||
* @throws RuntimeException Если ID отсутствует или невалиден
|
||||
*/
|
||||
private function extractTelegramUserId(array $telegramUserData): int
|
||||
{
|
||||
$telegramUserId = (int) Arr::get($telegramUserData, 'id');
|
||||
|
||||
if ($telegramUserId <= 0) {
|
||||
throw new RuntimeException('Telegram user ID is required and must be positive');
|
||||
}
|
||||
|
||||
return $telegramUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подготовить данные для сохранения в БД
|
||||
*
|
||||
* @param array $telegramUserData Исходные данные пользователя
|
||||
* @param int $telegramUserId Telegram user ID
|
||||
* @return array Подготовленные данные
|
||||
*/
|
||||
private function prepareCustomerData(array $telegramUserData, int $telegramUserId): array
|
||||
{
|
||||
return [
|
||||
'telegram_user_id' => $telegramUserId,
|
||||
'username' => Arr::get($telegramUserData, 'username'),
|
||||
'first_name' => Arr::get($telegramUserData, 'first_name'),
|
||||
'last_name' => Arr::get($telegramUserData, 'last_name'),
|
||||
'language_code' => Arr::get($telegramUserData, 'language_code'),
|
||||
'is_premium' => $this->convertToInt(Arr::get($telegramUserData, 'is_premium', false)),
|
||||
'allows_write_to_pm' => $this->convertToInt(Arr::get($telegramUserData, 'allows_write_to_pm', false)),
|
||||
'photo_url' => Arr::get($telegramUserData, 'photo_url'),
|
||||
'last_seen_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертировать булево значение в int для БД
|
||||
*
|
||||
* @param mixed $value Значение для конвертации
|
||||
* @return int 1 или 0
|
||||
*/
|
||||
private function convertToInt($value): int
|
||||
{
|
||||
return ($value === true || $value === 1 || $value === '1') ? 1 : 0;
|
||||
}
|
||||
}
|
||||
@@ -10,26 +10,22 @@ use App\Handlers\OrderHandler;
|
||||
use App\Handlers\ProductsHandler;
|
||||
use App\Handlers\SettingsHandler;
|
||||
use App\Handlers\TelegramHandler;
|
||||
use App\Handlers\TelegramCustomerHandler;
|
||||
|
||||
return [
|
||||
'health' => [HealthCheckHandler::class, 'handle'],
|
||||
|
||||
'products' => [ProductsHandler::class, 'index'],
|
||||
'product_show' => [ProductsHandler::class, 'show'],
|
||||
'storeOrder' => [OrderHandler::class, 'store'],
|
||||
'filtersForMainPage' => [FiltersHandler::class, 'getFiltersForMainPage'],
|
||||
|
||||
'categoriesList' => [CategoriesHandler::class, 'index'],
|
||||
|
||||
'checkout' => [CartHandler::class, 'checkout'],
|
||||
'filtersForMainPage' => [FiltersHandler::class, 'getFiltersForMainPage'],
|
||||
'getCart' => [CartHandler::class, 'index'],
|
||||
|
||||
'settings' => [SettingsHandler::class, 'index'],
|
||||
'manifest' => [SettingsHandler::class, 'manifest'],
|
||||
'testTgMessage' => [SettingsHandler::class, 'testTgMessage'],
|
||||
|
||||
'webhook' => [TelegramHandler::class, 'webhook'],
|
||||
'processBlock' => [BlocksHandler::class, 'processBlock'],
|
||||
|
||||
'getForm' => [FormsHandler::class, 'getForm'],
|
||||
'health' => [HealthCheckHandler::class, 'handle'],
|
||||
'manifest' => [SettingsHandler::class, 'manifest'],
|
||||
'processBlock' => [BlocksHandler::class, 'processBlock'],
|
||||
'product_show' => [ProductsHandler::class, 'show'],
|
||||
'products' => [ProductsHandler::class, 'index'],
|
||||
'saveTelegramCustomer' => [TelegramCustomerHandler::class, 'saveOrUpdate'],
|
||||
'settings' => [SettingsHandler::class, 'index'],
|
||||
'storeOrder' => [OrderHandler::class, 'store'],
|
||||
'testTgMessage' => [SettingsHandler::class, 'testTgMessage'],
|
||||
'webhook' => [TelegramHandler::class, 'webhook'],
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user