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
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
src/*
|
# Cursor ignore patterns
|
||||||
|
|
||||||
frontend/spa/node_modules
|
frontend/spa/node_modules
|
||||||
|
frontend/admin/node_modules
|
||||||
module/oc_telegram_shop/upload/oc_telegram_shop/vendor
|
module/oc_telegram_shop/upload/oc_telegram_shop/vendor
|
||||||
module/oc_telegram_shop/upload/image
|
module/oc_telegram_shop/upload/image
|
||||||
module/oc_telegram_shop/upload/oc_telegram_shop/.phpunit.cache
|
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:
|
phpcs:
|
||||||
docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash -c "./vendor/bin/phpcs --standard=PSR12 bastion framework src"
|
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:
|
test:
|
||||||
docker compose exec -w /module/oc_telegram_shop/upload/oc_telegram_shop web bash -c "./vendor/bin/phpunit --testdox tests/"
|
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>
|
<RouterLink :to="{name: 'formbuilder'}">Форма заказа</RouterLink>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li :class="{active: route.name === 'customers'}">
|
||||||
|
<RouterLink :to="{name: 'customers'}">Telegram Покупатели</RouterLink>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li :class="{active: route.name === 'logs'}">
|
<li :class="{active: route.name === 'logs'}">
|
||||||
<RouterLink :to="{name: 'logs'}">Журнал событий</RouterLink>
|
<RouterLink :to="{name: 'logs'}">Журнал событий</RouterLink>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -66,3 +66,11 @@ ul.formkit-options label {
|
|||||||
ul.formkit-options input[type="radio"] {
|
ul.formkit-options input[type="radio"] {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input.p-checkbox-input {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,49 @@ onReady(async () => {
|
|||||||
options: {
|
options: {
|
||||||
cssLayer: false, // если используешь Tailwind, отключает layering
|
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);
|
app.use(ToastService);
|
||||||
|
|||||||
@@ -8,19 +8,21 @@ import StoreView from "@/views/StoreView.vue";
|
|||||||
import MainPageView from "@/views/MainPageView.vue";
|
import MainPageView from "@/views/MainPageView.vue";
|
||||||
import LogsView from "@/views/LogsView.vue";
|
import LogsView from "@/views/LogsView.vue";
|
||||||
import FormBuilderView from "@/views/FormBuilderView.vue";
|
import FormBuilderView from "@/views/FormBuilderView.vue";
|
||||||
|
import CustomersView from "@/views/CustomersView.vue";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createMemoryHistory(),
|
history: createMemoryHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{path: '/', name: 'general', component: GeneralView},
|
{path: '/', name: 'general', component: GeneralView},
|
||||||
{path: '/orders', name: 'orders', component: OrdersView},
|
{path: '/customers', name: 'customers', component: CustomersView},
|
||||||
{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: '/formbuilder', name: 'formbuilder', component: FormBuilderView},
|
{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 ApplicationError from "@/ApplicationError.vue";
|
||||||
import AppMetaInitializer from "@/utils/AppMetaInitializer.ts";
|
import AppMetaInitializer from "@/utils/AppMetaInitializer.ts";
|
||||||
import {injectYaMetrika} from "@/utils/yaMetrika.js";
|
import {injectYaMetrika} from "@/utils/yaMetrika.js";
|
||||||
|
import {saveTelegramCustomer} from "@/utils/ftch.js";
|
||||||
|
|
||||||
import {register} from 'swiper/element/bundle';
|
import {register} from 'swiper/element/bundle';
|
||||||
import 'swiper/element/bundle';
|
import 'swiper/element/bundle';
|
||||||
@@ -44,6 +45,20 @@ settings.load()
|
|||||||
throw new Error('App disabled (maintenance mode)');
|
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(() => blocks.processBlocks(settings.mainpage_blocks))
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
console.debug('Load default filters for the main page');
|
console.debug('Load default filters for the main page');
|
||||||
|
|||||||
@@ -96,4 +96,15 @@ export async function processBlock(block) {
|
|||||||
return await ftch('processBlock', null, 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;
|
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\DictionariesHandler;
|
||||||
use Bastion\Handlers\FormsHandler;
|
use Bastion\Handlers\FormsHandler;
|
||||||
use Bastion\Handlers\LogsHandler;
|
use Bastion\Handlers\LogsHandler;
|
||||||
|
use Bastion\Handlers\SendMessageHandler;
|
||||||
use Bastion\Handlers\SettingsHandler;
|
use Bastion\Handlers\SettingsHandler;
|
||||||
use Bastion\Handlers\StatsHandler;
|
use Bastion\Handlers\StatsHandler;
|
||||||
|
use Bastion\Handlers\TelegramCustomersHandler;
|
||||||
use Bastion\Handlers\TelegramHandler;
|
use Bastion\Handlers\TelegramHandler;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'configureBotToken' => [SettingsHandler::class, 'configureBotToken'],
|
'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'],
|
'getAutocompleteCategories' => [AutocompleteHandler::class, 'getCategories'],
|
||||||
'getAutocompleteCategoriesFlat' => [AutocompleteHandler::class, 'getCategoriesFlat'],
|
'getAutocompleteCategoriesFlat' => [AutocompleteHandler::class, 'getCategoriesFlat'],
|
||||||
'resetCache' => [SettingsHandler::class, 'resetCache'],
|
'getCategories' => [DictionariesHandler::class, 'getCategories'],
|
||||||
'getLogs' => [LogsHandler::class, 'getLogs'],
|
'getCategoriesById' => [AutocompleteHandler::class, 'getCategoriesById'],
|
||||||
|
'getChatId' => [TelegramHandler::class, 'getChatId'],
|
||||||
|
'getDashboardStats' => [StatsHandler::class, 'getDashboardStats'],
|
||||||
'getFormByAlias' => [FormsHandler::class, 'getFormByAlias'],
|
'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 App\Services\SettingsService;
|
||||||
use Openguru\OpenCartFramework\Http\Request;
|
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 Openguru\OpenCartFramework\Telegram\Exceptions\TelegramInvalidSignatureException;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
class SignatureValidator
|
class SignatureValidator
|
||||||
{
|
{
|
||||||
private ?string $botToken;
|
private ?string $botToken;
|
||||||
private SettingsService $settings;
|
private SettingsService $settings;
|
||||||
private LoggerInterface $logger;
|
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->botToken = $botToken;
|
||||||
$this->settings = $settings;
|
$this->settings = $settings;
|
||||||
$this->logger = $logger;
|
$this->logger = $logger;
|
||||||
|
$this->initDataDecoder = $initDataDecoder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws TelegramInvalidSignatureException
|
||||||
|
*/
|
||||||
public function validate(Request $request): void
|
public function validate(Request $request): void
|
||||||
{
|
{
|
||||||
if ($this->settings->config()->getApp()->isAppDebug()) {
|
if ($this->settings->config()->getApp()->isAppDebug()) {
|
||||||
@@ -32,13 +43,15 @@ class SignatureValidator
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$initDataString = base64_decode($request->header('X-Telegram-Initdata'));
|
if (! $request->header(TelegramHeader::INIT_DATA)) {
|
||||||
|
throw new TelegramInvalidSignatureException('Telegram Signature not exists.');
|
||||||
if (! $initDataString) {
|
|
||||||
throw new TelegramInvalidSignatureException('Invalid Telegram signature!');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$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'])) {
|
if (! isset($data['hash'])) {
|
||||||
throw new TelegramInvalidSignatureException('Missing hash in init data');
|
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
|
private function getCheckString(array $data): string
|
||||||
{
|
{
|
||||||
unset($data['hash']);
|
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(
|
return new SignatureValidator(
|
||||||
$app->get(SettingsService::class),
|
$app->get(SettingsService::class),
|
||||||
$app->get(LoggerInterface::class),
|
$app->get(LoggerInterface::class),
|
||||||
|
$app->get(TelegramInitDataDecoder::class),
|
||||||
$app->getConfigValue('telegram.bot_token'),
|
$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\ProductsHandler;
|
||||||
use App\Handlers\SettingsHandler;
|
use App\Handlers\SettingsHandler;
|
||||||
use App\Handlers\TelegramHandler;
|
use App\Handlers\TelegramHandler;
|
||||||
|
use App\Handlers\TelegramCustomerHandler;
|
||||||
|
|
||||||
return [
|
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'],
|
'categoriesList' => [CategoriesHandler::class, 'index'],
|
||||||
|
|
||||||
'checkout' => [CartHandler::class, 'checkout'],
|
'checkout' => [CartHandler::class, 'checkout'],
|
||||||
|
'filtersForMainPage' => [FiltersHandler::class, 'getFiltersForMainPage'],
|
||||||
'getCart' => [CartHandler::class, 'index'],
|
'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'],
|
'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