diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md deleted file mode 100644 index 16f823c..0000000 --- a/API_DOCUMENTATION.md +++ /dev/null @@ -1,739 +0,0 @@ -# API Документация - -## Базовый URL - -``` -https://api.tgex.app/v1 -``` - -## Аутентификация - -Все запросы (кроме `/auth/*`) требуют JWT токен в заголовке: - -``` -Authorization: Bearer -``` - ---- - -## 1. Аутентификация - -### POST `/auth/init` - -Инициализация процесса авторизации через Telegram - -**Response:** - -```typescript -{ - bot_username: string; - start_param: string; // "login" -} -``` - -### POST `/auth/complete` - -Завершение авторизации с одноразовым токеном - -**Request:** - -```typescript -{ - token: string; // one-time token from bot -} -``` - -**Response:** - -```typescript -{ - access_token: string; - refresh_token: string; - user: User; -} -``` - -### POST `/auth/refresh` - -Обновление access токена - -**Request:** - -```typescript -{ - refresh_token: string; -} -``` - -**Response:** - -```typescript -{ - access_token: string; -} -``` - -### GET `/auth/me` - -Получение информации о текущем пользователе - -**Response:** - -```typescript -User; -``` - ---- - -## 2. Типы данных - -### User - -```typescript -{ - id: string; - telegram_id: number; - username: string | null; - first_name: string; - last_name: string | null; - created_at: string; // ISO 8601 -} -``` - -### TargetChannel - -```typescript -{ - id: string; - telegram_id: number; - title: string; - username: string | null; // @channel_name - description: string | null; - is_active: boolean; - created_at: string; - updated_at: string; - // Stats - total_purchases: number; - total_subscriptions: number; - avg_cpf: number | null; // Average Cost Per Follower -} -``` - -### ExternalChannel - -```typescript -{ - id: string; - telegram_id: number | null; - title: string; - username: string | null; - link: string; // t.me link or custom - subscribers_count: number | null; - description: string | null; - created_at: string; - updated_at: string; - // Relations - target_channels: string[]; // IDs of target channels - // Stats - total_purchases: number; - avg_cpf: number | null; - avg_cpm: number | null; -} -``` - -### Creative - -```typescript -{ - id: string; - name: string; - text: string; // Template with {link} placeholder - target_channel_id: string; - is_archived: boolean; - created_at: string; - updated_at: string; - // Relations - target_channel: TargetChannel; - // Stats - total_purchases: number; - total_subscriptions: number; - avg_cpf: number | null; -} -``` - -### Purchase - -```typescript -{ - id: string; - target_channel_id: string; - external_channel_id: string; - creative_id: string; - // Purchase details - scheduled_date: string | null; // ISO 8601 - actual_date: string | null; - cost: number | null; // in rubles - comment: string | null; - post_link: string | null; // Link to ad post in external channel - invite_link: string; // Generated invite link - invite_link_type: "public" | "private"; // with bot approval or not - is_archived: boolean; - created_at: string; - updated_at: string; - // Relations - target_channel: TargetChannel; - external_channel: ExternalChannel; - creative: Creative; - // Stats - subscriptions_count: number; - views_count: number | null; - cpf: number | null; // Cost Per Follower - cpm: number | null; // Cost Per Mile (1000 views) - conversion_rate: number | null; // subscriptions / views * 100 -} -``` - -### Subscription - -```typescript -{ - id: string; - purchase_id: string; - telegram_user_id: number; - username: string | null; - joined_at: string; // ISO 8601 - event_type: "chat_member" | "join_request"; -} -``` - -### ViewsSnapshot - -```typescript -{ - id: string; - purchase_id: string; - views: number; - fetched_at: string; // ISO 8601 -} -``` - ---- - -## 3. Целевые каналы - -### GET `/target-channels` - -Получение списка целевых каналов - -**Query params:** - -- `is_active?: boolean` - фильтр по активности - -**Response:** - -```typescript -{ - data: TargetChannel[]; - total: number; -} -``` - -### GET `/target-channels/:id` - -Получение детальной информации о канале - -**Response:** - -```typescript -TargetChannel & { - recent_purchases: Purchase[]; // last 5 - stats_by_period: { - period: 'day' | 'week' | 'month'; - subscriptions: number; - cost: number; - cpf: number; - }[]; -} -``` - -### PATCH `/target-channels/:id` - -Обновление канала (только is_active) - -**Request:** - -```typescript -{ - is_active: boolean; -} -``` - -**Response:** - -```typescript -TargetChannel; -``` - -### DELETE `/target-channels/:id` - -Отключение канала (soft delete) - -**Response:** - -```typescript -{ - success: true; -} -``` - ---- - -## 4. Внешние каналы - -### GET `/external-channels` - -Получение списка внешних каналов - -**Query params:** - -- `target_channel_id?: string` - фильтр по целевому каналу -- `search?: string` - поиск по названию/username - -**Response:** - -```typescript -{ - data: ExternalChannel[]; - total: number; -} -``` - -### GET `/external-channels/:id` - -Получение детальной информации - -**Response:** - -```typescript -ExternalChannel & { - purchases: Purchase[]; -} -``` - -### POST `/external-channels` - -Создание нового внешнего канала - -**Request:** - -```typescript -{ - title: string; - username?: string; - link: string; - subscribers_count?: number; - description?: string; - target_channel_ids: string[]; // Привязка к целевым каналам -} -``` - -**Response:** - -```typescript -ExternalChannel; -``` - -### PATCH `/external-channels/:id` - -Обновление внешнего канала - -**Request:** - -```typescript -Partial<{ - title: string; - username: string; - link: string; - subscribers_count: number; - description: string; - target_channel_ids: string[]; -}>; -``` - -**Response:** - -```typescript -ExternalChannel; -``` - -### DELETE `/external-channels/:id` - -Удаление внешнего канала - -**Response:** - -```typescript -{ - success: true; -} -``` - -### POST `/external-channels/import` - -Импорт из Excel (Tgstat) - -**Request:** - -``` -Content-Type: multipart/form-data -file: File (Excel) -target_channel_id: string -``` - -**Response:** - -```typescript -{ - imported: number; - skipped: number; - errors: string[]; - channels: ExternalChannel[]; -} -``` - ---- - -## 5. Креативы - -### GET `/creatives` - -Получение списка креативов - -**Query params:** - -- `target_channel_id?: string` - фильтр по целевому каналу -- `is_archived?: boolean` - фильтр по архивности - -**Response:** - -```typescript -{ - data: Creative[]; - total: number; -} -``` - -### GET `/creatives/:id` - -Получение детальной информации - -**Response:** - -```typescript -Creative & { - purchases: Purchase[]; -} -``` - -### POST `/creatives` - -Создание нового креатива - -**Request:** - -```typescript -{ - name: string; - text: string; // Must contain {link} placeholder - target_channel_id: string; -} -``` - -**Response:** - -```typescript -Creative; -``` - -### PATCH `/creatives/:id` - -Обновление креатива - -**Request:** - -```typescript -Partial<{ - name: string; - text: string; - is_archived: boolean; -}>; -``` - -**Response:** - -```typescript -Creative; -``` - -### DELETE `/creatives/:id` - -Удаление креатива (только если нет активных закупов) - -**Response:** - -```typescript -{ - success: true; -} -``` - ---- - -## 6. Закупы - -### GET `/purchases` - -Получение списка закупов - -**Query params:** - -- `target_channel_id?: string` -- `external_channel_id?: string` -- `creative_id?: string` -- `is_archived?: boolean` -- `date_from?: string` (ISO 8601) -- `date_to?: string` (ISO 8601) -- `sort?: 'date' | 'cost' | 'cpf' | 'subscriptions'` -- `order?: 'asc' | 'desc'` - -**Response:** - -```typescript -{ - data: Purchase[]; - total: number; -} -``` - -### GET `/purchases/:id` - -Получение детальной информации о закупе - -**Response:** - -```typescript -Purchase & { - subscriptions: Subscription[]; - views_history: ViewsSnapshot[]; -} -``` - -### POST `/purchases` - -Создание нового закупа - -**Request:** - -```typescript -{ - target_channel_id: string; - external_channel_id: string; - creative_id: string; - scheduled_date?: string; - actual_date?: string; - cost?: number; - comment?: string; - post_link?: string; - invite_link_type: 'public' | 'private'; -} -``` - -**Response:** - -```typescript -Purchase & { - formatted_message: string; // Creative text with invite_link inserted -} -``` - -### PATCH `/purchases/:id` - -Обновление закупа - -**Request:** - -```typescript -Partial<{ - scheduled_date: string; - actual_date: string; - cost: number; - comment: string; - post_link: string; - is_archived: boolean; -}>; -``` - -**Response:** - -```typescript -Purchase; -``` - -### DELETE `/purchases/:id` - -Удаление закупа - -**Response:** - -```typescript -{ - success: true; -} -``` - -### POST `/purchases/:id/refresh-views` - -Принудительное обновление просмотров - -**Response:** - -```typescript -{ - views: number; - fetched_at: string; -} -``` - ---- - -## 7. Аналитика - -### GET `/analytics/overview` - -Общая статистика - -**Query params:** - -- `target_channel_id?: string` -- `date_from?: string` -- `date_to?: string` - -**Response:** - -```typescript -{ - total_purchases: number; - total_cost: number; - total_subscriptions: number; - avg_cpf: number; - avg_cpm: number; - avg_conversion_rate: number; -} -``` - -### GET `/analytics/costs` - -Данные для графика затрат - -**Query params:** - -- `target_channel_id?: string` -- `date_from: string` (required) -- `date_to: string` (required) -- `group_by: 'day' | 'week' | 'month' | 'quarter' | 'year'` - -**Response:** - -```typescript -{ - data: { - period: string; // ISO 8601 date (start of period) - cost: number; - purchases_count: number; - subscriptions: number; - } - []; -} -``` - -### GET `/analytics/channels` - -Аналитика по каналам - -**Query params:** - -- `target_channel_id?: string` -- `date_from?: string` -- `date_to?: string` -- `type: 'external' | 'target'` - -**Response:** - -```typescript -{ - data: { - channel_id: string; - channel_name: string; - purchases_count: number; - total_cost: number; - total_subscriptions: number; - avg_cpf: number; - avg_cpm: number; - } - []; -} -``` - -### GET `/analytics/creatives` - -Аналитика по креативам - -**Query params:** - -- `target_channel_id?: string` -- `date_from?: string` -- `date_to?: string` - -**Response:** - -```typescript -{ - data: { - creative_id: string; - creative_name: string; - purchases_count: number; - total_cost: number; - total_subscriptions: number; - avg_cpf: number; - conversion_rate: number; - } - []; -} -``` - ---- - -## 8. Коды ошибок - -```typescript -{ - error: { - code: string; - message: string; - details?: any; - } -} -``` - -Основные коды: - -- `UNAUTHORIZED` - 401 -- `FORBIDDEN` - 403 -- `NOT_FOUND` - 404 -- `VALIDATION_ERROR` - 422 -- `INTERNAL_ERROR` - 500 -- `CHANNEL_NOT_FOUND` - 404 -- `CHANNEL_ALREADY_EXISTS` - 409 -- `INSUFFICIENT_PERMISSIONS` - 403 -- `CREATIVE_IN_USE` - 409 -- `INVALID_INVITE_LINK` - 422 diff --git a/FRONTEND_API.md b/FRONTEND_API.md new file mode 100644 index 0000000..cb46020 --- /dev/null +++ b/FRONTEND_API.md @@ -0,0 +1,704 @@ +# API Документация для Frontend + +## Базовая информация + +- **Base URL**: `/api/v1` (для всех эндпоинтов) +- **Формат данных**: JSON +- **Аутентификация**: JWT Bearer Token (в заголовке `Authorization: Bearer `) + +## Авторизация + +### Процесс авторизации через Telegram + +1. Пользователь запускает Telegram бота командой `/start` с параметром `login` +2. Бот создает одноразовый токен (живет 10 минут) и отправляет ссылку на веб-приложение +3. Фронтенд получает `token` из URL параметра (`/auth/complete?token=...`) +4. Фронтенд отправляет запрос на `GET /api/v1/auth/complete?token=` +5. Бэкенд возвращает JWT токен, который нужно сохранить и использовать для всех последующих запросов + +### GET `/api/v1/auth/complete` + +Обмен одноразового токена на JWT access token. + +**Query параметры:** + +- `token` (string, обязательный) - одноразовый токен из Telegram бота + +**Response 200:** + +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +**JWT токен содержит:** + +- `user_id` (UUID) - ID пользователя +- `telegram_id` (integer) - Telegram ID пользователя +- `username` (string | null) - Telegram username +- `exp` - время истечения токена (7 дней по умолчанию) + +**Ошибки:** + +- `404` - токен не найден +- `400` - токен уже использован или истек + +### GET `/api/v1/auth/me` + +Получить информацию о текущем пользователе. + +**Headers:** + +- `Authorization: Bearer ` + +**Response 200:** + +```json +{ + "id": "550e8400-e29b-41d4-a716-446655440000", + "telegram_id": 123456789, + "username": "user_name" +} +``` + +**Поля:** + +- `id` (UUID) - ID пользователя в системе +- `telegram_id` (integer) - Telegram ID +- `username` (string | null) - Telegram username (может быть null) + +--- + +## Целевые каналы (Target Channels) + +Каналы пользователя, в которых установлен бот. + +### GET `/api/v1/target_channels` + +Получить список всех целевых каналов пользователя. + +**Headers:** + +- `Authorization: Bearer ` + +**Response 200:** + +```json +{ + "target_channels": [ + { + "id": "uuid", + "telegram_id": 123456789, + "title": "Название канала", + "username": "channel_username", + "is_active": true + } + ] +} +``` + +### DELETE `/api/v1/target_channels/{channel_id}` + +Отключить целевой канал (удалить из системы). + +**Headers:** + +- `Authorization: Bearer ` + +**Path параметры:** + +- `channel_id` (UUID) - ID канала + +**Response 200:** пустой ответ + +--- + +## Внешние каналы (External Channels) + +Каналы, в которых размещается реклама. + +### GET `/api/v1/external_channels/target/{target_channel_id}` + +Получить все внешние каналы, связанные с конкретным целевым каналом. + +**Headers:** + +- `Authorization: Bearer ` + +**Path параметры:** + +- `target_channel_id` (UUID) - ID целевого канала + +**Response 200:** + +```json +{ + "external_channels": [ + { + "id": "uuid", + "telegram_id": 123456789, + "title": "Внешний канал", + "username": "external_channel", + "description": "Описание канала", + "subscribers_count": 10000 + } + ] +} +``` + +### POST `/api/v1/external_channels` + +Создать новый внешний канал. + +**Headers:** + +- `Authorization: Bearer ` + +**Request body:** + +```json +{ + "telegram_id": 123456789, + "title": "Название канала", + "username": "channel_username", + "description": "Описание", + "subscribers_count": 10000, + "target_channel_ids": ["uuid1", "uuid2"] +} +``` + +**Поля:** + +- `telegram_id` (integer, обязательный) - Telegram ID канала +- `title` (string, обязательный) - название канала +- `username` (string, optional) - username канала (без @) +- `description` (string, optional) - описание канала +- `subscribers_count` (integer, optional) - количество подписчиков +- `target_channel_ids` (array of UUID, обязательный) - список ID целевых каналов для связи + +**Response 200:** объект `ExternalChannelOutput` + +### PATCH `/api/v1/external_channels/{channel_id}` + +Обновить информацию о внешнем канале. + +**Headers:** + +- `Authorization: Bearer ` + +**Path параметры:** + +- `channel_id` (UUID) - ID канала + +**Request body:** + +```json +{ + "title": "Новое название", + "username": "new_username", + "description": "Новое описание", + "subscribers_count": 15000 +} +``` + +**Все поля опциональны.** Отправляйте только те, которые нужно изменить. + +**Response 200:** объект `ExternalChannelOutput` + +### PATCH `/api/v1/external_channels/{channel_id}/links` + +Обновить связи внешнего канала с целевыми каналами. + +**Headers:** + +- `Authorization: Bearer ` + +**Path параметры:** + +- `channel_id` (UUID) - ID канала + +**Request body:** + +```json +{ + "add_target_channel_ids": ["uuid1", "uuid2"], + "remove_target_channel_ids": ["uuid3"] +} +``` + +**Поля:** + +- `add_target_channel_ids` (array of UUID, optional, default=[]) - добавить связи +- `remove_target_channel_ids` (array of UUID, optional, default=[]) - удалить связи + +**Response 200:** объект `ExternalChannelOutput` + +### DELETE `/api/v1/external_channels/{channel_id}` + +Удалить внешний канал. + +**Headers:** + +- `Authorization: Bearer ` + +**Path параметры:** + +- `channel_id` (UUID) - ID канала + +**Response 200:** пустой ответ + +--- + +## Креативы (Creatives) + +Рекламные сообщения для размещения. + +### GET `/api/v1/creatives` + +Получить список креативов. + +**Headers:** + +- `Authorization: Bearer ` + +**Query параметры:** + +- `target_channel_id` (UUID, optional) - фильтр по целевому каналу +- `include_archived` (boolean, optional, default=false) - включить архивные + +**Response 200:** + +```json +{ + "creatives": [ + { + "id": "uuid", + "name": "Название креатива", + "text": "Текст рекламного сообщения", + "target_channel_id": "uuid", + "target_channel_title": "Название канала", + "created_at": "2024-01-01T12:00:00Z", + "status": "active", + "placements_count": 5 + } + ] +} +``` + +**Статусы креатива:** + +- `active` - активный +- `archived` - архивный + +### GET `/api/v1/creatives/{creative_id}` + +Получить конкретный креатив. + +**Headers:** + +- `Authorization: Bearer ` + +**Path параметры:** + +- `creative_id` (UUID) - ID креатива + +**Response 200:** объект `CreativeOutput` + +### POST `/api/v1/creatives` + +Создать новый креатив. + +**Headers:** + +- `Authorization: Bearer ` + +**Request body:** + +```json +{ + "name": "Название креатива", + "text": "Текст рекламного сообщения", + "target_channel_id": "uuid" +} +``` + +**Поля:** + +- `name` (string, обязательный) - название креатива +- `text` (string, обязательный) - текст сообщения +- `target_channel_id` (UUID, обязательный) - ID целевого канала + +**Response 200:** объект `CreativeOutput` + +### PATCH `/api/v1/creatives/{creative_id}` + +Обновить креатив. + +**Headers:** + +- `Authorization: Bearer ` + +**Path параметры:** + +- `creative_id` (UUID) - ID креатива + +**Request body:** + +```json +{ + "name": "Новое название", + "text": "Новый текст", + "status": "archived" +} +``` + +**Все поля опциональны.** + +**Response 200:** объект `CreativeOutput` + +### DELETE `/api/v1/creatives/{creative_id}` + +Удалить креатив. + +**Headers:** + +- `Authorization: Bearer ` + +**Path параметры:** + +- `creative_id` (UUID) - ID креатива + +**Response 200:** пустой ответ + +--- + +## Размещения (Placements) + +Размещения рекламы во внешних каналах. + +### GET `/api/v1/placements` + +Получить список размещений. + +**Headers:** + +- `Authorization: Bearer ` + +**Query параметры:** + +- `target_channel_id` (UUID, optional) - фильтр по целевому каналу +- `external_channel_id` (UUID, optional) - фильтр по внешнему каналу +- `creative_id` (UUID, optional) - фильтр по креативу +- `include_archived` (boolean, optional, default=false) - включить архивные + +**Response 200:** + +```json +{ + "placements": [ + { + "id": "uuid", + "target_channel_id": "uuid", + "target_channel_title": "Целевой канал", + "external_channel_id": "uuid", + "external_channel_title": "Внешний канал", + "creative_id": "uuid", + "creative_name": "Название креатива", + "placement_date": "2024-01-01T12:00:00Z", + "cost": 1000.5, + "comment": "Комментарий", + "ad_post_url": "https://t.me/channel/123", + "invite_link_type": "approval", + "invite_link": "https://t.me/+unique_invite_link", + "status": "active", + "subscriptions_count": 42, + "views_count": 1500, + "views_availability": "available", + "last_views_fetch_at": "2024-01-02T10:00:00Z", + "created_at": "2024-01-01T12:00:00Z" + } + ] +} +``` + +**Типы пригласительных ссылок (invite_link_type):** + +- `public` - обычная открытая ссылка +- `approval` - ссылка с одобрением бота (отслеживание подписчиков) + +**Статусы размещения (status):** + +- `active` - активное +- `archived` - архивное + +**Доступность просмотров (views_availability):** + +- `unknown` - ещё не проверялось +- `available` - просмотры доступны через API +- `unavailable` - нет доступа к просмотрам +- `manual` - просмотры вводятся вручную + +### GET `/api/v1/placements/{placement_id}` + +Получить конкретное размещение. + +**Headers:** + +- `Authorization: Bearer ` + +**Path параметры:** + +- `placement_id` (UUID) - ID размещения + +**Response 200:** объект `PlacementOutput` + +### POST `/api/v1/placements` + +Создать новое размещение. + +**Headers:** + +- `Authorization: Bearer ` + +**Request body:** + +```json +{ + "target_channel_id": "uuid", + "external_channel_id": "uuid", + "creative_id": "uuid", + "placement_date": "2024-01-01T12:00:00Z", + "cost": 1000.5, + "comment": "Комментарий", + "ad_post_url": "https://t.me/channel/123", + "invite_link_type": "approval" +} +``` + +**Поля:** + +- `target_channel_id` (UUID, обязательный) +- `external_channel_id` (UUID, обязательный) +- `creative_id` (UUID, обязательный) +- `placement_date` (datetime, обязательный) - дата размещения +- `cost` (float, optional) - стоимость размещения +- `comment` (string, optional) - комментарий +- `ad_post_url` (string, optional) - ссылка на рекламный пост +- `invite_link_type` (enum, optional, default="public") - тип пригласительной ссылки + +**Response 200:** объект `PlacementOutput` + +### PATCH `/api/v1/placements/{placement_id}` + +Обновить размещение. + +**Headers:** + +- `Authorization: Bearer ` + +**Path параметры:** + +- `placement_id` (UUID) - ID размещения + +**Request body:** + +```json +{ + "placement_date": "2024-01-02T12:00:00Z", + "cost": 1500.0, + "comment": "Новый комментарий", + "ad_post_url": "https://t.me/channel/124", + "status": "archived" +} +``` + +**Все поля опциональны.** + +**Response 200:** объект `PlacementOutput` + +### DELETE `/api/v1/placements/{placement_id}` + +Удалить размещение. + +**Headers:** + +- `Authorization: Bearer ` + +**Path параметры:** + +- `placement_id` (UUID) - ID размещения + +**Response 200:** пустой ответ + +--- + +## Просмотры (Views) + +Работа с просмотрами рекламных постов. + +### GET `/api/v1/placements/{placement_id}/views/history` + +Получить историю просмотров размещения. + +**Headers:** + +- `Authorization: Bearer ` + +**Path параметры:** + +- `placement_id` (UUID) - ID размещения + +**Query параметры:** + +- `from_date` (datetime, optional) - начало периода +- `to_date` (datetime, optional) - конец периода + +**Response 200:** + +```json +{ + "histories": [ + { + "id": "uuid", + "placement_id": "uuid", + "views_count": 1500, + "fetched_at": "2024-01-02T10:00:00Z", + "created_at": "2024-01-02T10:00:00Z" + } + ] +} +``` + +### POST `/api/v1/placements/{placement_id}/views/fetch` + +Получить актуальные просмотры через Telegram API (запросить обновление). + +**Headers:** + +- `Authorization: Bearer ` + +**Path параметры:** + +- `placement_id` (UUID) - ID размещения + +**Response 200:** + +```json +{ + "placement_id": "uuid", + "views_count": 1500, + "views_availability": "available", + "fetched_at": "2024-01-02T10:00:00Z", + "error_message": null +} +``` + +**При ошибке получения просмотров:** + +```json +{ + "placement_id": "uuid", + "views_count": null, + "views_availability": "unavailable", + "fetched_at": "2024-01-02T10:00:00Z", + "error_message": "Не удалось получить просмотры: нет доступа к каналу" +} +``` + +### POST `/api/v1/placements/{placement_id}/views/manual` + +Обновить просмотры вручную (когда автоматическое получение недоступно). + +**Headers:** + +- `Authorization: Bearer ` + +**Path параметры:** + +- `placement_id` (UUID) - ID размещения + +**Query параметры:** + +- `views_count` (integer, обязательный) - количество просмотров + +**Response 200:** объект `PlacementOutput` (обновленное размещение) + +--- + +## Типы данных и enum + +### DateTime + +Все даты в формате ISO 8601 с timezone (UTC): `2024-01-01T12:00:00Z` + +### UUID + +Строковое представление UUID: `"550e8400-e29b-41d4-a716-446655440000"` + +### CreativeStatus (enum) + +- `"active"` - активный +- `"archived"` - архивный + +### PlacementStatus (enum) + +- `"active"` - активное +- `"archived"` - архивное + +### InviteLinkType (enum) + +- `"public"` - открытая публичная ссылка +- `"approval"` - ссылка с одобрением ботом + +### PostViewsAvailability (enum) + +- `"unknown"` - статус не определен +- `"available"` - просмотры доступны через API +- `"unavailable"` - просмотры недоступны +- `"manual"` - просмотры вводятся вручную + +--- + +## Обработка ошибок + +### Формат ошибки + +Все ошибки возвращаются в стандартном формате FastAPI: + +```json +{ + "detail": "Описание ошибки" +} +``` + +### HTTP коды ответов + +- `200` - успешный запрос +- `401` - не авторизован (невалидный/истекший токен) +- `403` - доступ запрещен (нет прав на ресурс) +- `404` - ресурс не найден +- `422` - ошибка валидации входных данных +- `500` - внутренняя ошибка сервера + +### Авторизация + +Для всех защищенных эндпоинтов (все, кроме `/api/v1/auth/complete`) необходимо передавать JWT токен: + +``` +Authorization: Bearer +``` + +При отсутствии или невалидном токене вернется `401 Unauthorized`. + +--- + +## Примечания + +1. **Soft delete**: При удалении сущностей (креативы, размещения, внешние каналы) они помечаются как удаленные (`deleted_at`), но не удаляются физически из БД. + +2. **Отслеживание подписчиков**: Работает только для размещений с `invite_link_type: "approval"`. Бот автоматически одобряет запросы на вступление и увеличивает счетчик подписок. + +3. **Автоматическое обновление просмотров**: Фоновый worker периодически обновляет просмотры для активных размещений. Можно также запросить обновление вручную через `/api/v1/placements/{id}/views/fetch`. + +4. **Целевые каналы подключаются только через Telegram бота**: Нельзя создать целевой канал через API. Нужно добавить бота в канал как администратора, и он автоматически зарегистрирует канал в системе. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md deleted file mode 100644 index 933a5c9..0000000 --- a/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,352 +0,0 @@ -# План реализации фронтенда - -## Стек технологий - -- **Framework:** Next.js 14 (App Router) -- **UI:** shadcn/ui + Tailwind CSS -- **Icons:** Lucide React -- **Package Manager:** pnpm -- **State Management:** React Context + Hooks -- **Forms:** React Hook Form + Zod -- **API Client:** Fetch API + SWR/React Query -- **Charts:** Recharts (для аналитики) - ---- - -## Структура проекта - -``` -frontend/ -├── app/ -│ ├── (auth)/ -│ │ ├── login/ -│ │ │ └── page.tsx -│ │ └── auth-complete/ -│ │ └── page.tsx -│ ├── (dashboard)/ -│ │ ├── layout.tsx # Layout с sidebar-07 -│ │ ├── page.tsx # Dashboard-01 (главная) -│ │ ├── channels/ -│ │ │ ├── page.tsx # Список целевых каналов -│ │ │ └── [id]/ -│ │ │ └── page.tsx # Детали канала -│ │ ├── external-channels/ -│ │ │ ├── page.tsx # Каталог внешних каналов -│ │ │ ├── [id]/ -│ │ │ │ └── page.tsx # Детали внешнего канала -│ │ │ └── import/ -│ │ │ └── page.tsx # Импорт из Excel -│ │ ├── creatives/ -│ │ │ ├── page.tsx # Список креативов -│ │ │ ├── new/ -│ │ │ │ └── page.tsx # Создание креатива -│ │ │ └── [id]/ -│ │ │ ├── page.tsx # Просмотр/редактирование -│ │ │ └── edit/ -│ │ │ └── page.tsx -│ │ ├── purchases/ -│ │ │ ├── page.tsx # Список закупов -│ │ │ ├── new/ -│ │ │ │ └── page.tsx # Создание закупа -│ │ │ └── [id]/ -│ │ │ ├── page.tsx # Детали закупа -│ │ │ └── edit/ -│ │ │ └── page.tsx -│ │ └── analytics/ -│ │ ├── page.tsx # Общая аналитика -│ │ ├── costs/ -│ │ │ └── page.tsx # График затрат -│ │ ├── channels/ -│ │ │ └── page.tsx # Аналитика по каналам -│ │ └── creatives/ -│ │ └── page.tsx # Аналитика по креативам -│ ├── globals.css -│ ├── layout.tsx -│ └── providers.tsx -├── components/ -│ ├── ui/ # shadcn компоненты -│ ├── auth/ -│ │ ├── auth-provider.tsx -│ │ └── protected-route.tsx -│ ├── channels/ -│ │ ├── channel-card.tsx -│ │ ├── channel-form.tsx -│ │ └── channel-stats.tsx -│ ├── external-channels/ -│ │ ├── external-channel-card.tsx -│ │ ├── external-channel-form.tsx -│ │ ├── external-channel-import-dialog.tsx -│ │ └── channel-picker.tsx -│ ├── creatives/ -│ │ ├── creative-card.tsx -│ │ ├── creative-form.tsx -│ │ └── creative-preview.tsx -│ ├── purchases/ -│ │ ├── purchase-card.tsx -│ │ ├── purchase-form.tsx -│ │ ├── purchase-stats.tsx -│ │ └── invite-link-display.tsx -│ ├── analytics/ -│ │ ├── overview-card.tsx -│ │ ├── cost-chart.tsx -│ │ ├── channel-comparison.tsx -│ │ └── metric-card.tsx -│ └── layout/ -│ ├── app-sidebar.tsx # sidebar-07 -│ ├── header.tsx -│ └── breadcrumbs.tsx -├── lib/ -│ ├── api/ -│ │ ├── client.ts # API client с перехватчиками -│ │ ├── auth.ts # Auth endpoints -│ │ ├── channels.ts # Target channels endpoints -│ │ ├── external-channels.ts # External channels endpoints -│ │ ├── creatives.ts # Creatives endpoints -│ │ ├── purchases.ts # Purchases endpoints -│ │ └── analytics.ts # Analytics endpoints -│ ├── mocks/ -│ │ ├── index.ts # Мок-сервер (флаг USE_MOCKS) -│ │ ├── data/ -│ │ │ ├── users.ts -│ │ │ ├── channels.ts -│ │ │ ├── external-channels.ts -│ │ │ ├── creatives.ts -│ │ │ ├── purchases.ts -│ │ │ └── analytics.ts -│ │ └── handlers.ts # Request handlers для моков -│ ├── types/ -│ │ ├── api.ts # API типы из документации -│ │ └── common.ts -│ ├── hooks/ -│ │ ├── use-auth.ts -│ │ ├── use-channels.ts -│ │ ├── use-external-channels.ts -│ │ ├── use-creatives.ts -│ │ ├── use-purchases.ts -│ │ └── use-analytics.ts -│ ├── utils/ -│ │ ├── format.ts # Форматирование (даты, числа) -│ │ ├── validation.ts # Zod схемы -│ │ └── constants.ts -│ └── store/ -│ └── auth-store.ts # Глобальный стейт авторизации -├── public/ -├── .env.local.example -├── .env.local -├── next.config.js -├── tailwind.config.ts -├── components.json -├── tsconfig.json -├── package.json -└── API_DOCUMENTATION.md -``` - ---- - -## Этапы реализации - -### Этап 1: Базовая инфраструктура ✅ - -- [x] Создать API документацию -- [x] Создать план реализации -- [ ] Настроить переменные окружения -- [ ] Создать типы TypeScript из API документации -- [ ] Настроить API клиент с поддержкой моков -- [ ] Создать мок-данные и handlers - -### Этап 2: Авторизация - -- [ ] Создать AuthProvider с Context -- [ ] Реализовать страницу входа -- [ ] Реализовать страницу завершения авторизации -- [ ] Добавить Protected Route HOC -- [ ] Реализовать мок-авторизацию (кнопка "Войти как тестовый пользователь") -- [ ] Настроить хранение токенов (localStorage) - -### Этап 3: Layout и навигация - -- [ ] Установить shadcn sidebar-07 -- [ ] Установить shadcn dashboard-01 -- [ ] Настроить app-sidebar с навигацией -- [ ] Создать header с профилем пользователя -- [ ] Добавить breadcrumbs навигацию -- [ ] Настроить responsive layout - -### Этап 4: Целевые каналы - -- [ ] Страница списка целевых каналов -- [ ] Карточки каналов с основной статистикой -- [ ] Страница детальной информации о канале -- [ ] Функция отключения канала -- [ ] Инструкция по подключению бота - -### Этап 5: Внешние каналы - -- [ ] Страница каталога внешних каналов -- [ ] Форма создания внешнего канала -- [ ] Форма редактирования внешнего канала -- [ ] Привязка к целевым каналам (multi-select) -- [ ] Страница импорта из Excel -- [ ] Drag & drop для Excel файлов -- [ ] Валидация и preview импорта -- [ ] Обработка ошибок импорта - -### Этап 6: Креативы - -- [ ] Страница списка креативов -- [ ] Форма создания креатива -- [ ] Редактор текста с placeholder {link} -- [ ] Превью креатива с подстановкой ссылки -- [ ] Форма редактирования креатива -- [ ] Функция архивирования -- [ ] Статистика по креативу - -### Этап 7: Закупы - -- [ ] Страница списка закупов (таблица) -- [ ] Фильтры и сортировка -- [ ] Форма создания закупа (multi-step) -- [ ] Генерация invite-ссылки -- [ ] Отображение форматированного сообщения -- [ ] Копирование в буфер обмена -- [ ] Страница детальной информации о закупе -- [ ] График подписок по времени -- [ ] История просмотров -- [ ] Обновление просмотров вручную -- [ ] Редактирование закупа -- [ ] Архивирование/удаление - -### Этап 8: Аналитика - -- [ ] Dashboard с общей статистикой (dashboard-01) -- [ ] Карточки метрик (CPF, CPM, конверсия) -- [ ] Страница графика затрат -- [ ] Выбор периода группировки (день/неделя/месяц) -- [ ] Фильтр по целевым каналам -- [ ] Столбчатая диаграмма (recharts) -- [ ] Страница аналитики по каналам -- [ ] Таблица сравнения каналов -- [ ] Страница аналитики по креативам -- [ ] Рейтинг креативов по эффективности - -### Этап 9: Доработки и полировка - -- [ ] Адаптивность для мобильных устройств -- [ ] Обработка состояний загрузки -- [ ] Обработка ошибок с user-friendly сообщениями -- [ ] Добавить Toast уведомления -- [ ] Анимации и переходы -- [ ] Оптимизация производительности -- [ ] Accessibility (a11y) -- [ ] SEO метатеги - -### Этап 10: Тестирование - -- [ ] Проверка всех флоу с моками -- [ ] Проверка валидации форм -- [ ] Проверка авторизации -- [ ] Проверка responsive дизайна -- [ ] Проверка dark mode (если поддерживается) -- [ ] Подготовка презентации для команды - ---- - -## Работа с моками - -### Переменная окружения - -```env -NEXT_PUBLIC_USE_MOCKS=true -NEXT_PUBLIC_API_URL=https://api.tgex.app/v1 -``` - -### Логика переключения - -```typescript -// lib/api/client.ts -const USE_MOCKS = process.env.NEXT_PUBLIC_USE_MOCKS === "true"; - -if (USE_MOCKS) { - // Используем локальные моки - return mockHandler(endpoint, options); -} else { - // Реальный API запрос - return fetch(`${API_URL}${endpoint}`, options); -} -``` - -### Мок-авторизация - -При включенных моках на странице логина будет кнопка: - -- "Войти как Иван Иванов" - мгновенный вход без Telegram - -### Преимущества подхода - -1. **Полная независимость** от бекенда -2. **Быстрая разработка** - нет задержек сети -3. **Предсказуемые данные** - всегда одинаковые моки -4. **Простое тестирование** - можно тестировать edge cases -5. **Демонстрация** - презентация работает без сервера - ---- - -## Ключевые компоненты shadcn/ui - -Необходимо установить: - -```bash -npx shadcn@latest add sidebar-07 -npx shadcn@latest add dashboard-01 -npx shadcn@latest add button -npx shadcn@latest add input -npx shadcn@latest add textarea -npx shadcn@latest add select -npx shadcn@latest add dialog -npx shadcn@latest add table -npx shadcn@latest add card -npx shadcn@latest add badge -npx shadcn@latest add dropdown-menu -npx shadcn@latest add toast -npx shadcn@latest add form -npx shadcn@latest add tabs -npx shadcn@latest add separator -npx shadcn@latest add avatar -npx shadcn@latest add calendar -npx shadcn@latest add popover -``` - ---- - -## Приоритеты разработки - -1. **Высокий:** - - - Авторизация - - Layout с sidebar - - Целевые каналы (просмотр) - - Закупы (создание и просмотр) - - Базовая аналитика - -2. **Средний:** - - - Креативы - - Внешние каналы (CRUD) - - Детальная аналитика - - Импорт Excel - -3. **Низкий:** - - Продвинутая фильтрация - - Экспорт данных - - Дополнительные виджеты - ---- - -## Метрики успеха презентации - -- ✅ Полный флоу от входа до создания закупа -- ✅ Работающая аналитика с графиками -- ✅ Адаптивный дизайн -- ✅ Плавные анимации -- ✅ Быстрая загрузка (моки) -- ✅ Понятный UX diff --git a/app/(auth)/auth-complete/page.tsx b/app/(auth)/auth/complete/page.tsx similarity index 97% rename from app/(auth)/auth-complete/page.tsx rename to app/(auth)/auth/complete/page.tsx index 12016f9..9612891 100644 --- a/app/(auth)/auth-complete/page.tsx +++ b/app/(auth)/auth/complete/page.tsx @@ -47,7 +47,7 @@ function AuthCompleteContent() { }, 1500); } catch (err: any) { setStatus("error"); - setError(err?.error?.message || "Ошибка авторизации"); + setError(err?.detail || err?.error?.message || "Ошибка авторизации"); } }; diff --git a/app/(dashboard)/analytics/costs/page.tsx b/app/(dashboard)/analytics/costs/page.tsx index 91764b4..6a26a5b 100644 --- a/app/(dashboard)/analytics/costs/page.tsx +++ b/app/(dashboard)/analytics/costs/page.tsx @@ -59,12 +59,12 @@ export default function AnalyticsCostsPage() { const [reportData, channelsData] = await Promise.all([ analyticsApi.costs({ period, target_channel_id: targetChannelId }), channels.length > 0 - ? Promise.resolve({ data: channels }) - : channelsApi.list({}), + ? Promise.resolve({ target_channels: channels }) + : channelsApi.list(), ]); setReport(reportData); if (channels.length === 0) { - setChannels(channelsData.data); + setChannels(channelsData.target_channels); } } catch (err: any) { setError(err?.error?.message || "Ошибка загрузки аналитики"); diff --git a/app/(dashboard)/channels/[id]/page.tsx b/app/(dashboard)/channels/[id]/page.tsx index e78dc13..750e804 100644 --- a/app/(dashboard)/channels/[id]/page.tsx +++ b/app/(dashboard)/channels/[id]/page.tsx @@ -4,357 +4,23 @@ // Target Channel Detail Page // ============================================================================ -import React, { useEffect, useState } from "react"; -import { use } from "react"; -import Link from "next/link"; +import React, { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { DashboardHeader } from "@/components/layout/dashboard-header"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { channelsApi } from "@/lib/api"; -import { - formatNumber, - formatMetric, - formatCurrency, - formatUsername, - formatDate, -} from "@/lib/utils/format"; -import { - Target, - Users, - TrendingUp, - ArrowLeft, - Loader2, - AlertCircle, - ExternalLink as ExternalLinkIcon, - BarChart3, -} from "lucide-react"; -import type { TargetChannelDetail } from "@/lib/types/api"; +import { use } from "react"; interface PageProps { params: Promise<{ id: string }>; } export default function ChannelDetailPage({ params }: PageProps) { - const resolvedParams = use(params); const router = useRouter(); - const [channel, setChannel] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const resolvedParams = use(params); useEffect(() => { - const loadChannel = async () => { - try { - setLoading(true); - const data = await channelsApi.get(resolvedParams.id); - setChannel(data); - } catch (err: any) { - setError(err?.error?.message || "Ошибка загрузки канала"); - } finally { - setLoading(false); - } - }; + // Redirect back to channels list + // Detail page functionality not yet implemented in backend API + router.push("/channels"); + }, [router, resolvedParams.id]); - loadChannel(); - }, [resolvedParams.id]); - - const handleToggleActive = async () => { - if (!channel) return; - - try { - const updated = await channelsApi.update(channel.id, { - is_active: !channel.is_active, - }); - setChannel({ ...channel, is_active: updated.is_active }); - } catch (err: any) { - alert(err?.error?.message || "Ошибка обновления канала"); - } - }; - - const handleDelete = async () => { - if (!channel) return; - if (!confirm("Вы уверены, что хотите отключить этот канал?")) return; - - try { - await channelsApi.delete(channel.id); - router.push("/channels"); - } catch (err: any) { - alert(err?.error?.message || "Ошибка удаления канала"); - } - }; - - if (loading) { - return ( - <> - -
- -
- - ); - } - - if (error || !channel) { - return ( - <> - -
- - - {error || "Канал не найден"} - - -
- - ); - } - - return ( - <> - -
-
-
-
- -

- {channel.title} -

- - {channel.is_active ? "Активен" : "Отключен"} - -
-
- {channel.username ? ( - - {formatUsername(channel.username)} - - - ) : ( - Приватный канал - )} - - Добавлен {formatDate(channel.created_at)} -
-
-
- - -
-
- - {channel.description && ( - - - Описание - - -

{channel.description}

-
-
- )} - -
- - - - Всего закупов - - - - -
- {formatNumber(channel.total_purchases)} -
-

- За весь период -

-
-
- - - - - Подписчиков привлечено - - - - -
- {formatNumber(channel.total_subscriptions)} -
-

Всего

-
-
- - - - Средний CPF - - - -
- ₽{formatMetric(channel.avg_cpf)} -
-

- Стоимость подписчика -

-
-
-
- - - - Статистика по периодам - Последние закупы - - - - - - Статистика по периодам - - Динамика привлечения подписчиков - - - - - - - Период - Подписчики - Затраты - CPF - - - - {channel.stats_by_period.map((stat, index) => ( - - - {stat.period} - - - {formatNumber(stat.subscriptions)} - - - {formatCurrency(stat.cost)} - - - ₽{formatMetric(stat.cpf)} - - - ))} - -
-
-
-
- - - - - Последние закупы - - 5 последних рекламных размещений - - - - {channel.recent_purchases.length === 0 ? ( -

- Закупов пока нет -

- ) : ( - - - - Внешний канал - Дата - Подписчики - CPF - - - - - {channel.recent_purchases.map((purchase) => ( - - - {purchase.external_channel.title} - - - {formatDate( - purchase.actual_date || purchase.scheduled_date - )} - - - {formatNumber(purchase.subscriptions_count)} - - - ₽{formatMetric(purchase.cpf)} - - - - - - ))} - -
- )} -
-
-
-
-
- - ); + return null; } diff --git a/app/(dashboard)/channels/page.tsx b/app/(dashboard)/channels/page.tsx index 9fbd14e..cd6b618 100644 --- a/app/(dashboard)/channels/page.tsx +++ b/app/(dashboard)/channels/page.tsx @@ -45,9 +45,11 @@ export default function ChannelsPage() { try { setLoading(true); const response = await channelsApi.list(); - setChannels(response.data); + setChannels(response.target_channels); } catch (err: any) { - setError(err?.error?.message || "Ошибка загрузки каналов"); + setError( + err?.error?.message || err?.detail || "Ошибка загрузки каналов" + ); } finally { setLoading(false); } @@ -62,14 +64,16 @@ export default function ChannelsPage() { return true; }); - const handleToggleActive = async (id: string, isActive: boolean) => { + const handleDisconnect = async (id: string, title: string) => { + if (!confirm(`Вы уверены, что хотите отключить канал "${title}"?`)) { + return; + } + try { - await channelsApi.update(id, { is_active: !isActive }); - setChannels((prev) => - prev.map((ch) => (ch.id === id ? { ...ch, is_active: !isActive } : ch)) - ); + await channelsApi.delete(id); + setChannels((prev) => prev.filter((ch) => ch.id !== id)); } catch (err: any) { - alert(err?.error?.message || "Ошибка обновления канала"); + alert(err?.error?.message || err?.detail || "Ошибка отключения канала"); } }; @@ -213,59 +217,23 @@ export default function ChannelsPage() { - {channel.description && ( -

- {channel.description} -

- )} - -
-
-
- {formatNumber(channel.total_purchases)} -
-
- Закупов -
-
-
-
- {formatNumber(channel.total_subscriptions)} -
-
- Подписчиков -
-
-
-
- ₽{formatMetric(channel.avg_cpf)} -
-
- CPF -
-
+
+ Telegram ID:{" "} + + {channel.telegram_id} +
-
diff --git a/app/(dashboard)/creatives/[id]/page.tsx b/app/(dashboard)/creatives/[id]/page.tsx index 8d91c5f..f935388 100644 --- a/app/(dashboard)/creatives/[id]/page.tsx +++ b/app/(dashboard)/creatives/[id]/page.tsx @@ -45,7 +45,7 @@ import { Users, TrendingUp, } from "lucide-react"; -import type { CreativeDetail } from "@/lib/types/api"; +import type { Creative } from "@/lib/types/api"; interface PageProps { params: Promise<{ id: string }>; @@ -54,7 +54,7 @@ interface PageProps { export default function CreativeDetailPage({ params }: PageProps) { const resolvedParams = use(params); const router = useRouter(); - const [creative, setCreative] = useState(null); + const [creative, setCreative] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -79,9 +79,12 @@ export default function CreativeDetailPage({ params }: PageProps) { try { await creativesApi.update(creative.id, { - is_archived: !creative.is_archived, + status: creative.status === "active" ? "archived" : "active", + }); + setCreative({ + ...creative, + status: creative.status === "active" ? "archived" : "active", }); - setCreative({ ...creative, is_archived: !creative.is_archived }); } catch (err: any) { alert(err?.error?.message || "Ошибка обновления креатива"); } @@ -162,16 +165,20 @@ export default function CreativeDetailPage({ params }: PageProps) {

{creative.name}

- {creative.is_archived && Архив} + {creative.status === "archived" && ( + Архив + )}
- Целевой канал: {creative.target_channel.title} + Целевой канал: {creative.target_channel_title}
+
+
diff --git a/app/(dashboard)/creatives/new/page.tsx b/app/(dashboard)/creatives/new/page.tsx index 18e8ed2..c6a6207 100644 --- a/app/(dashboard)/creatives/new/page.tsx +++ b/app/(dashboard)/creatives/new/page.tsx @@ -48,8 +48,8 @@ export default function CreateCreativePage() { const loadChannels = async () => { try { - const response = await channelsApi.list({ is_active: true }); - setChannels(response.data); + const response = await channelsApi.list(); + setChannels(response.target_channels.filter((c) => c.is_active)); } catch (err) { console.error("Error loading channels:", err); } diff --git a/app/(dashboard)/creatives/page.tsx b/app/(dashboard)/creatives/page.tsx index 8567abc..8975de6 100644 --- a/app/(dashboard)/creatives/page.tsx +++ b/app/(dashboard)/creatives/page.tsx @@ -18,7 +18,12 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { creativesApi } from "@/lib/api"; -import { formatNumber, formatMetric, truncate } from "@/lib/utils/format"; +import { + formatNumber, + formatMetric, + truncate, + formatDate, +} from "@/lib/utils/format"; import { Folder, Loader2, @@ -48,7 +53,7 @@ export default function CreativesPage() { try { setLoading(true); const response = await creativesApi.list(); - setCreatives(response.data); + setCreatives(response.creatives); } catch (err: any) { setError(err?.error?.message || "Ошибка загрузки креативов"); } finally { @@ -56,9 +61,13 @@ export default function CreativesPage() { } }; - const handleArchive = async (id: string, isArchived: boolean) => { + const handleArchive = async ( + id: string, + currentStatus: "active" | "archived" + ) => { try { - await creativesApi.update(id, { is_archived: !isArchived }); + const newStatus = currentStatus === "active" ? "archived" : "active"; + await creativesApi.update(id, { status: newStatus }); loadCreatives(); } catch (err: any) { alert(err?.error?.message || "Ошибка обновления креатива"); @@ -77,8 +86,8 @@ export default function CreativesPage() { }; const filteredCreatives = creatives.filter((creative) => { - if (activeTab === "active") return !creative.is_archived; - if (activeTab === "archived") return creative.is_archived; + if (activeTab === "active") return creative.status === "active"; + if (activeTab === "archived") return creative.status === "archived"; return true; }); @@ -117,10 +126,10 @@ export default function CreativesPage() { > - Активные ({creatives.filter((c) => !c.is_archived).length}) + Активные ({creatives.filter((c) => c.status === "active").length}) - Архив ({creatives.filter((c) => c.is_archived).length}) + Архив ({creatives.filter((c) => c.status === "archived").length}) Все ({creatives.length}) @@ -166,12 +175,12 @@ export default function CreativesPage() { {creative.name} - {creative.is_archived && ( + {creative.status === "archived" && ( Архив )} - {creative.target_channel.title} + {creative.target_channel_title} @@ -184,29 +193,21 @@ export default function CreativesPage() {

-
+
- {formatNumber(creative.total_purchases)} + {formatNumber(creative.placements_count)}
- Закупов + Размещений
-
- {formatNumber(creative.total_subscriptions)} +
+ {formatDate(creative.created_at)}
- Подписчиков -
-
-
-
- ₽{formatMetric(creative.avg_cpf)} -
-
- CPF + Создан
@@ -227,7 +228,7 @@ export default function CreativesPage() { variant="ghost" size="sm" onClick={() => - handleArchive(creative.id, creative.is_archived) + handleArchive(creative.id, creative.status) } > diff --git a/app/(dashboard)/external-channels/[id]/page.tsx b/app/(dashboard)/external-channels/[id]/page.tsx index 0aa9483..95af753 100644 --- a/app/(dashboard)/external-channels/[id]/page.tsx +++ b/app/(dashboard)/external-channels/[id]/page.tsx @@ -4,317 +4,23 @@ // External Channel Detail Page // ============================================================================ -import React, { useEffect, useState } from "react"; -import { use } from "react"; -import Link from "next/link"; +import React, { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { DashboardHeader } from "@/components/layout/dashboard-header"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { externalChannelsApi } from "@/lib/api"; -import { - formatNumber, - formatMetric, - formatCurrency, - formatUsername, - formatDate, - formatCompactNumber, -} from "@/lib/utils/format"; -import { - ExternalLink as ExternalLinkIcon, - Users, - TrendingUp, - ArrowLeft, - Loader2, - AlertCircle, - BarChart3, - Pencil, - Trash2, -} from "lucide-react"; -import type { ExternalChannelDetail } from "@/lib/types/api"; +import { use } from "react"; interface PageProps { params: Promise<{ id: string }>; } export default function ExternalChannelDetailPage({ params }: PageProps) { - const resolvedParams = use(params); const router = useRouter(); - const [channel, setChannel] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const resolvedParams = use(params); useEffect(() => { - const loadChannel = async () => { - try { - setLoading(true); - const data = await externalChannelsApi.get(resolvedParams.id); - setChannel(data); - } catch (err: any) { - setError(err?.error?.message || "Ошибка загрузки канала"); - } finally { - setLoading(false); - } - }; + // Redirect back to external channels list + // Detail page functionality not yet implemented in backend API + router.push("/external-channels"); + }, [router, resolvedParams.id]); - loadChannel(); - }, [resolvedParams.id]); - - const handleDelete = async () => { - if (!channel) return; - if (!confirm(`Удалить канал "${channel.title}"?`)) return; - - try { - await externalChannelsApi.delete(channel.id); - router.push("/external-channels"); - } catch (err: any) { - alert(err?.error?.message || "Ошибка удаления канала"); - } - }; - - if (loading) { - return ( - <> - -
- -
- - ); - } - - if (error || !channel) { - return ( - <> - -
- - - {error || "Канал не найден"} - - -
- - ); - } - - return ( - <> - -
-
-
-
- -

- {channel.title} -

-
-
- {channel.username ? ( - - {formatUsername(channel.username)} - - - ) : ( - - {channel.link} - - - )} - - Добавлен {formatDate(channel.created_at)} -
-
-
- -
-
- - {channel.description && ( - - - Описание - - -

{channel.description}

-
-
- )} - -
- - - Подписчиков - - - -
- {channel.subscribers_count - ? formatCompactNumber(channel.subscribers_count) - : "—"} -
-

В канале

-
-
- - - - - Всего закупов - - - - -
- {formatNumber(channel.total_purchases)} -
-

- В этом канале -

-
-
- - - - Средний CPF - - - -
- ₽{formatMetric(channel.avg_cpf)} -
-

- По всем закупам -

-
-
- - - - Средний CPM - - - -
- ₽{formatMetric(channel.avg_cpm)} -
-

- Стоимость 1000 просмотров -

-
-
-
- - - - История закупов - - Все рекламные размещения в этом канале - - - - {channel.purchases.length === 0 ? ( -

- Закупов в этом канале пока нет -

- ) : ( - - - - Целевой канал - Дата - Стоимость - Подписчики - CPF - - - - - {channel.purchases.map((purchase) => ( - - - {purchase.target_channel.title} - - - {formatDate( - purchase.actual_date || purchase.scheduled_date - )} - - - {formatCurrency(purchase.cost)} - - - {formatNumber(purchase.subscriptions_count)} - - - ₽{formatMetric(purchase.cpf)} - - - - - - ))} - -
- )} -
-
-
- - ); + return null; } diff --git a/app/(dashboard)/external-channels/import/page.tsx b/app/(dashboard)/external-channels/import/page.tsx index 8afea39..cb259cd 100644 --- a/app/(dashboard)/external-channels/import/page.tsx +++ b/app/(dashboard)/external-channels/import/page.tsx @@ -4,7 +4,7 @@ // External Channels Import Page // ============================================================================ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { DashboardHeader } from "@/components/layout/dashboard-header"; @@ -17,8 +17,20 @@ import { } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; -import { externalChannelsApi } from "@/lib/api"; +import { Progress } from "@/components/ui/progress"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { externalChannelsApi, channelsApi } from "@/lib/api"; +import { + parseChannelsFile, + downloadTemplate, + type ParsedChannel, +} from "@/lib/utils/file-parser"; import { FileUp, AlertCircle, @@ -27,18 +39,46 @@ import { Download, ArrowLeft, X, + FileSpreadsheet, } from "lucide-react"; -import type { ExternalChannelImportResponse } from "@/lib/types/api"; +import type { TargetChannel } from "@/lib/types/api"; + +interface ImportResult { + total: number; + successful: number; + failed: number; + errors: string[]; +} export default function ImportExternalChannelsPage() { const router = useRouter(); const [file, setFile] = useState(null); const [isDragging, setIsDragging] = useState(false); const [isUploading, setIsUploading] = useState(false); - const [result, setResult] = useState( - null - ); - const [error, setError] = useState(null); + const [parsedChannels, setParsedChannels] = useState([]); + const [parseErrors, setParseErrors] = useState([]); + const [importResult, setImportResult] = useState(null); + const [progress, setProgress] = useState(0); + const [currentChannel, setCurrentChannel] = useState(""); + const [targetChannels, setTargetChannels] = useState([]); + const [selectedTargetChannel, setSelectedTargetChannel] = + useState(""); + + useEffect(() => { + loadTargetChannels(); + }, []); + + const loadTargetChannels = async () => { + try { + const response = await channelsApi.list(); + setTargetChannels(response.target_channels); + if (response.target_channels.length > 0) { + setSelectedTargetChannel(response.target_channels[0].id); + } + } catch (err) { + console.error("Error loading target channels:", err); + } + }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); @@ -59,25 +99,29 @@ export default function ImportExternalChannelsPage() { } }; - const handleFileSelect = (selectedFile: File) => { - // Проверка типа файла - const validTypes = [ - "application/vnd.ms-excel", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "text/csv", - ]; - - if ( - !validTypes.includes(selectedFile.type) && - !selectedFile.name.match(/\.(xlsx|xls|csv)$/i) - ) { - setError("Пожалуйста, выберите файл Excel (.xlsx, .xls) или CSV"); + const handleFileSelect = async (selectedFile: File) => { + if (!selectedFile.name.match(/\.(xlsx|xls|csv)$/i)) { + setParseErrors(["Пожалуйста, выберите файл Excel (.xlsx, .xls) или CSV"]); return; } setFile(selectedFile); - setError(null); - setResult(null); + setParseErrors([]); + setParsedChannels([]); + setImportResult(null); + + // Парсим файл + try { + const result = await parseChannelsFile(selectedFile); + setParsedChannels(result.channels); + setParseErrors(result.errors); + } catch (err) { + setParseErrors([ + `Ошибка парсинга файла: ${ + err instanceof Error ? err.message : "неизвестная ошибка" + }`, + ]); + } }; const handleFileInputChange = (e: React.ChangeEvent) => { @@ -87,33 +131,66 @@ export default function ImportExternalChannelsPage() { } }; - const handleUpload = async () => { - if (!file) return; + const delay = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); - try { - setIsUploading(true); - setError(null); + const handleImport = async () => { + if (!selectedTargetChannel || parsedChannels.length === 0) return; - // Для примера используем первый целевой канал (tc1) - // В реальном приложении нужно дать пользователю выбрать - const importResult = await externalChannelsApi.import(file, "tc1"); - setResult(importResult); - setFile(null); - } catch (err: any) { - setError(err?.error?.message || "Ошибка импорта файла"); - } finally { - setIsUploading(false); + setIsUploading(true); + setImportResult(null); + setProgress(0); + setCurrentChannel(""); + + const result: ImportResult = { + total: parsedChannels.length, + successful: 0, + failed: 0, + errors: [], + }; + + for (let i = 0; i < parsedChannels.length; i++) { + const channel = parsedChannels[i]; + setCurrentChannel(channel.title); + setProgress(((i + 1) / parsedChannels.length) * 100); + + try { + await externalChannelsApi.create({ + telegram_id: channel.telegram_id || 0, + title: channel.title, + username: channel.username || null, + description: channel.description || null, + subscribers_count: channel.subscribers_count || null, + target_channel_ids: [selectedTargetChannel], + }); + + result.successful++; + } catch (err: any) { + result.failed++; + const errorMsg = + err?.detail || err?.error?.message || "Неизвестная ошибка"; + result.errors.push( + `Строка ${channel.row} (${channel.title}): ${errorMsg}` + ); + } + + // Задержка между запросами (300ms) + if (i < parsedChannels.length - 1) { + await delay(300); + } } + + setImportResult(result); + setIsUploading(false); + setCurrentChannel(""); }; const handleReset = () => { setFile(null); - setResult(null); - setError(null); - }; - - const handleGoToChannels = () => { - router.push("/external-channels"); + setParsedChannels([]); + setParseErrors([]); + setImportResult(null); + setProgress(0); }; return ( @@ -132,7 +209,7 @@ export default function ImportExternalChannelsPage() { Импорт внешних каналов

- Загрузите файл Excel с данными из Tgstat + Загрузите файл Excel или CSV с данными каналов

+ {/* Выбор целевого канала */} - Формат файла + Целевой канал - Требования к структуре Excel файла + Выберите целевой канал для привязки импортируемых каналов -
-

Файл должен содержать следующие колонки:

-
    -
  • - Название - название канала -
  • -
  • - Username - username канала (с @ или без) -
  • -
  • - Ссылка - ссылка на канал (t.me/...) -
  • -
  • - Подписчики - количество подписчиков (число) -
  • -
  • - Описание - краткое описание (опционально) -
  • -
-
- -
+ + + + + {/* Шаблоны */} + + + Скачать шаблон + + Используйте шаблон для правильного заполнения данных + + + + + + + + + {/* Загрузка файла */} + + + Загрузка файла + + Перетащите файл или нажмите для выбора + + + +
+ +
- {!result ? ( + {/* Результаты парсинга */} + {parsedChannels.length > 0 && !importResult && ( - Загрузка файла + Результаты парсинга - Перетащите файл или выберите его + Найдено каналов: {parsedChannels.length} -
- {file ? ( -
-
- -
-
-

{file.name}

-

- {(file.size / 1024).toFixed(2)} KB -

-
- -
- ) : ( -
-
- -
-
-

- Перетащите файл сюда -

-

- или нажмите кнопку ниже -

-
-
- - -
-
- )} -
- - {error && ( + {parseErrors.length > 0 && ( - {error} + + Ошибки при парсинге: +
    + {parseErrors.slice(0, 5).map((error, i) => ( +
  • + {error} +
  • + ))} + {parseErrors.length > 5 && ( +
  • + ...и еще {parseErrors.length - 5} +
  • + )} +
+
)} - {file && ( -
- - -
- )} +
+ + +
- ) : ( + )} + + {/* Прогресс импорта */} + {isUploading && ( -
- - Импорт завершен -
- Результаты обработки файла + Импорт в процессе + + {currentChannel && `Импортируется: ${currentChannel}`} +
-
-
- - Импортировано каналов - - {result.imported} + +

+ {Math.round(progress)}% завершено +

+ + + )} + + {/* Результаты импорта */} + {importResult && ( + + + Результаты импорта + + +
+
+
{importResult.total}
+
Всего
- {result.skipped > 0 && ( -
- Пропущено - {result.skipped} +
+
+ {importResult.successful}
- )} +
Успешно
+
+
+
+ {importResult.failed} +
+
Ошибок
+
- {result.errors.length > 0 && ( - + {importResult.errors.length > 0 && ( + -

- Обнаружены ошибки при импорте: -

-
    - {result.errors.map((error, index) => ( -
  • {error}
  • + Ошибки импорта: +
      + {importResult.errors.map((error, i) => ( +
    • + {error} +
    • ))}
    )} -
    + {importResult.successful > 0 && ( + + + + Успешно импортировано каналов: {importResult.successful} + + + )} + +
    + -
    )} + + {/* Инструкция */} + + + Формат файла + Требования к структуре файла + + +
    +

    + Колонки (в порядке): +

    +
      +
    1. + Название канала* - обязательное поле +
    2. +
    3. + Username - без символа @ (опционально) +
    4. +
    5. + Telegram ID - числовой ID канала + (опционально) +
    6. +
    7. + Подписчиков - количество подписчиков + (опционально) +
    8. +
    9. + Описание - описание канала (опционально) +
    10. +
    +

    + * Первая строка файла должна содержать заголовки и будет + пропущена при импорте +

    +
    +
    +
    ); diff --git a/app/(dashboard)/external-channels/page.tsx b/app/(dashboard)/external-channels/page.tsx index 35c069e..0fbdd14 100644 --- a/app/(dashboard)/external-channels/page.tsx +++ b/app/(dashboard)/external-channels/page.tsx @@ -17,7 +17,7 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; -import { externalChannelsApi } from "@/lib/api"; +import { externalChannelsApi, channelsApi } from "@/lib/api"; import { formatNumber, formatMetric, @@ -74,10 +74,24 @@ export default function ExternalChannelsPage() { const loadChannels = async () => { try { setLoading(true); - const response = await externalChannelsApi.list(); - setChannels(response.data); + // Сначала загружаем target channels + const targetChannelsRes = await channelsApi.list(); + if (targetChannelsRes.target_channels.length === 0) { + setError("Добавьте сначала целевой канал"); + setLoading(false); + return; + } + + // Загружаем external channels для первого target channel + const firstTargetChannel = targetChannelsRes.target_channels[0]; + const response = await externalChannelsApi.list(firstTargetChannel.id); + setChannels(response.external_channels); } catch (err: any) { - setError(err?.error?.message || "Ошибка загрузки каналов"); + const errorMsg = + typeof err?.detail === "string" + ? err.detail + : err?.error?.message || "Ошибка загрузки каналов"; + setError(errorMsg); } finally { setLoading(false); } @@ -86,15 +100,23 @@ export default function ExternalChannelsPage() { const handleCreate = async () => { try { setIsCreating(true); + // Получаем первый доступный target channel + const targetChannelsRes = await channelsApi.list(); + if (targetChannelsRes.target_channels.length === 0) { + alert("Добавьте сначала целевой канал"); + setIsCreating(false); + return; + } + await externalChannelsApi.create({ + telegram_id: 0, // Временно, пока не получим реальный ID title: formData.title, - username: formData.username || undefined, - link: formData.link, + username: formData.username || null, subscribers_count: formData.subscribers_count ? parseInt(formData.subscribers_count) - : undefined, - description: formData.description || undefined, - target_channel_ids: [], // По умолчанию не привязываем + : null, + description: formData.description || null, + target_channel_ids: [targetChannelsRes.target_channels[0].id], }); setIsCreateDialogOpen(false); setFormData({ @@ -106,7 +128,11 @@ export default function ExternalChannelsPage() { }); loadChannels(); } catch (err: any) { - alert(err?.error?.message || "Ошибка создания канала"); + const errorMsg = + typeof err?.detail === "string" + ? err.detail + : err?.error?.message || "Ошибка создания канала"; + alert(errorMsg); } finally { setIsCreating(false); } @@ -317,7 +343,7 @@ export default function ExternalChannelsPage() { {channel.username ? ( ) : ( - - {channel.link} - + + ID: {channel.telegram_id} + )}
@@ -345,8 +366,8 @@ export default function ExternalChannelsPage() {

)} -
-
+
+
@@ -355,44 +376,29 @@ export default function ExternalChannelsPage() { : "—"}
-
+
Подписчики
-
+
- {formatNumber(channel.total_purchases)} + ID: {channel.telegram_id}
- Закупов + Telegram ID
-
-
- ₽{formatMetric(channel.avg_cpf)} -
-
CPF
-
-
diff --git a/app/(dashboard)/purchases/[id]/page.tsx b/app/(dashboard)/purchases/[id]/page.tsx index 90be2c4..1118629 100644 --- a/app/(dashboard)/purchases/[id]/page.tsx +++ b/app/(dashboard)/purchases/[id]/page.tsx @@ -19,15 +19,6 @@ import { import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { purchasesApi } from "@/lib/api"; import { formatNumber, @@ -54,7 +45,7 @@ import { Clock, CheckCircle, } from "lucide-react"; -import type { PurchaseDetail } from "@/lib/types/api"; +import type { Placement } from "@/lib/types/api"; interface PageProps { params: Promise<{ id: string }>; @@ -63,7 +54,7 @@ interface PageProps { export default function PurchaseDetailPage({ params }: PageProps) { const resolvedParams = use(params); const router = useRouter(); - const [purchase, setPurchase] = useState(null); + const [purchase, setPurchase] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); @@ -89,9 +80,12 @@ export default function PurchaseDetailPage({ params }: PageProps) { try { await purchasesApi.update(purchase.id, { - is_archived: !purchase.is_archived, + status: purchase.status === "active" ? "archived" : "active", + }); + setPurchase({ + ...purchase, + status: purchase.status === "active" ? "archived" : "active", }); - setPurchase({ ...purchase, is_archived: !purchase.is_archived }); } catch (err: any) { alert(err?.error?.message || "Ошибка обновления закупа"); } @@ -157,10 +151,8 @@ export default function PurchaseDetailPage({ params }: PageProps) { ); } - const messageText = purchase.creative.text.replace( - "{link}", - purchase.invite_link - ); + // Remove message text generation as we don't have creative.text in Placement + const messageText = `Текст сообщения с ссылкой: ${purchase.invite_link}`; return ( <> @@ -177,19 +169,14 @@ export default function PurchaseDetailPage({ params }: PageProps) {

- {purchase.external_channel.title} + {purchase.external_channel_title}

- {purchase.is_archived ? ( + {purchase.status === "archived" ? ( Архив - ) : purchase.actual_date ? ( + ) : ( - Завершено - - ) : ( - - - Запланировано + Активно )}
@@ -197,33 +184,34 @@ export default function PurchaseDetailPage({ params }: PageProps) { Целевой канал:{" "} - {purchase.target_channel.title} + {purchase.target_channel_title} Креатив:{" "} - {purchase.creative.name} + {purchase.creative_name} - Дата размещения:{" "} - {formatDate(purchase.actual_date || purchase.scheduled_date)} + Дата размещения: {formatDate(purchase.placement_date)}
- {purchase.post_link && ( + {purchase.ad_post_url && (
- {purchase.post_link} + {purchase.ad_post_url}
@@ -363,127 +369,21 @@ export default function PurchaseDetailPage({ params }: PageProps) { )} - - - - Подписки ({purchase.subscriptions.length}) - - {purchase.views_history && purchase.views_history.length > 0 && ( - - История просмотров ({purchase.views_history.length}) - - )} - - - - - - Подписки - - Пользователи, перешедшие по ссылке - - - - {purchase.subscriptions.length === 0 ? ( -

- Подписок пока нет -

- ) : ( - - - - Пользователь - Username - Дата подписки - Статус - - - - {purchase.subscriptions.map((subscription) => ( - - - {subscription.user_first_name}{" "} - {subscription.user_last_name} - - - {subscription.user_username - ? formatUsername(subscription.user_username) - : "—"} - - - {formatDate(subscription.subscribed_at)} - - - {subscription.is_active ? ( - Активен - ) : ( - Отписался - )} - - - ))} - -
- )} -
-
-
- - {purchase.views_history && purchase.views_history.length > 0 && ( - - - - История просмотров - - Динамика просмотров рекламного поста - - - - - - - Дата - Просмотры - Прирост - - - - {purchase.views_history.map((item, index) => { - const prevViews = - index > 0 - ? purchase.views_history![index - 1].views - : 0; - const growth = item.views - prevViews; - - return ( - - {formatDate(item.fetched_at)} - - {formatNumber(item.views)} - - - {index > 0 && ( - 0 - ? "text-green-600" - : "text-muted-foreground" - } - > - +{formatNumber(growth)} - - )} - - - ); - })} - -
-
-
-
- )} -
+ + + Заметка + + Детальная информация о размещении доступна через раздел "Подписки" + в главном меню + + + +

+ Для просмотра списка подписчиков и истории просмотров используйте + соответствующие разделы в главном меню. +

+
+
); diff --git a/app/(dashboard)/purchases/new/page.tsx b/app/(dashboard)/purchases/new/page.tsx index 10ae418..b6d8844 100644 --- a/app/(dashboard)/purchases/new/page.tsx +++ b/app/(dashboard)/purchases/new/page.tsx @@ -60,11 +60,11 @@ export default function CreatePurchasePage() { target_channel_id: "", external_channel_id: "", creative_id: "", - scheduled_date: "", + placement_date: "", cost: "", comment: "", post_link: "", - invite_link_type: "public" as "public" | "private", + invite_link_type: "public" as "public" | "approval", }); useEffect(() => { @@ -73,15 +73,24 @@ export default function CreatePurchasePage() { const loadData = async () => { try { - const [channelsRes, externalChannelsRes, creativesRes] = - await Promise.all([ - channelsApi.list({ is_active: true }), - externalChannelsApi.list(), + const channelsRes = await channelsApi.list(); + setChannels(channelsRes.target_channels.filter((c) => c.is_active)); + + // Загружаем внешние каналы для первого доступного target channel + if (channelsRes.target_channels.length > 0) { + const firstTargetChannel = channelsRes.target_channels[0]; + const [externalChannelsRes, creativesRes] = await Promise.all([ + externalChannelsApi.list(firstTargetChannel.id), creativesApi.list(), ]); - setChannels(channelsRes.data); - setExternalChannels(externalChannelsRes.data); - setCreatives(creativesRes.data.filter((c) => !c.is_archived)); + setExternalChannels(externalChannelsRes.external_channels); + setCreatives( + creativesRes.creatives.filter((c) => c.status === "active") + ); + } else { + setCreatives([]); + setExternalChannels([]); + } } catch (err) { console.error("Error loading data:", err); } @@ -116,7 +125,7 @@ export default function CreatePurchasePage() { return; } - if (!formData.scheduled_date) { + if (!formData.placement_date) { setError("Укажите дату размещения"); return; } @@ -132,10 +141,10 @@ export default function CreatePurchasePage() { target_channel_id: formData.target_channel_id, external_channel_id: formData.external_channel_id, creative_id: formData.creative_id, - scheduled_date: formData.scheduled_date, + placement_date: formData.placement_date, cost: parseFloat(formData.cost), comment: formData.comment || undefined, - post_link: formData.post_link || undefined, + ad_post_url: formData.post_link || undefined, invite_link_type: formData.invite_link_type, }); @@ -247,7 +256,7 @@ export default function CreatePurchasePage() { target_channel_id: "", external_channel_id: "", creative_id: "", - scheduled_date: "", + placement_date: "", cost: "", comment: "", post_link: "", @@ -394,15 +403,15 @@ export default function CreatePurchasePage() {
- + setFormData({ ...formData, - scheduled_date: e.target.value, + placement_date: e.target.value, }) } /> @@ -443,7 +452,7 @@ export default function CreatePurchasePage() { + onValueChange={(value: "public" | "approval") => setFormData({ ...formData, invite_link_type: value }) } > @@ -454,8 +463,8 @@ export default function CreatePurchasePage() {
- -
diff --git a/app/(dashboard)/purchases/page.tsx b/app/(dashboard)/purchases/page.tsx index 269cff8..6d9eb86 100644 --- a/app/(dashboard)/purchases/page.tsx +++ b/app/(dashboard)/purchases/page.tsx @@ -73,10 +73,10 @@ export default function PurchasesPage() { setLoading(true); const [purchasesRes, channelsRes] = await Promise.all([ purchasesApi.list(), - channelsApi.list({}), + channelsApi.list(), ]); - setPurchases(purchasesRes.data); - setChannels(channelsRes.data); + setPurchases(purchasesRes.placements); + setChannels(channelsRes.target_channels); } catch (err: any) { setError(err?.error?.message || "Ошибка загрузки данных"); } finally { @@ -87,26 +87,24 @@ export default function PurchasesPage() { const filteredPurchases = purchases.filter((purchase) => { // Поиск const matchesSearch = - purchase.external_channel.title + purchase.external_channel_title .toLowerCase() .includes(searchQuery.toLowerCase()) || - purchase.target_channel.title + purchase.target_channel_title .toLowerCase() .includes(searchQuery.toLowerCase()) || - purchase.creative.name.toLowerCase().includes(searchQuery.toLowerCase()); + purchase.creative_name.toLowerCase().includes(searchQuery.toLowerCase()); // Фильтр по каналу const matchesChannel = - filterChannel === "all" || purchase.target_channel.id === filterChannel; + filterChannel === "all" || purchase.target_channel_id === filterChannel; // Фильтр по статусу let matchesStatus = true; - if (filterStatus === "completed") { - matchesStatus = !!purchase.actual_date; - } else if (filterStatus === "scheduled") { - matchesStatus = !purchase.actual_date; - } else if (filterStatus === "archived") { - matchesStatus = purchase.is_archived; + if (filterStatus === "archived") { + matchesStatus = purchase.status === "archived"; + } else if (filterStatus === "active") { + matchesStatus = purchase.status === "active"; } return matchesSearch && matchesChannel && matchesStatus; @@ -115,17 +113,13 @@ export default function PurchasesPage() { // Статистика const stats = { total: purchases.length, - completed: purchases.filter((p) => p.actual_date).length, - scheduled: purchases.filter((p) => !p.actual_date).length, + active: purchases.filter((p) => p.status === "active").length, + archived: purchases.filter((p) => p.status === "archived").length, totalCost: purchases.reduce((sum, p) => sum + (p.cost || 0), 0), totalSubscriptions: purchases.reduce( (sum, p) => sum + p.subscriptions_count, 0 ), - avgCpf: - purchases.length > 0 - ? purchases.reduce((sum, p) => sum + (p.cpf || 0), 0) / purchases.length - : 0, }; return ( @@ -169,7 +163,7 @@ export default function PurchasesPage() { {formatNumber(stats.total)}

- {stats.completed} завершено, {stats.scheduled} запланировано + {stats.active} активных, {stats.archived} в архиве

@@ -203,20 +197,6 @@ export default function PurchasesPage() {

Всего

- - - - Средний CPF - - -
- ₽{formatMetric(stats.avgCpf)} -
-

- По всем закупам -

-
-
@@ -308,9 +288,8 @@ export default function PurchasesPage() { Креатив Дата Стоимость - Подписчики - CPF - Конверсия + Подписки + Просмотры @@ -318,34 +297,27 @@ export default function PurchasesPage() { {filteredPurchases.map((purchase) => ( - {purchase.is_archived ? ( + {purchase.status === "archived" ? ( Архив - ) : purchase.actual_date ? ( + ) : ( - Завершено - - ) : ( - - - Запланировано + Активно )} - {purchase.target_channel.title} + {purchase.target_channel_title} - {purchase.external_channel.title} + {purchase.external_channel_title} - {purchase.creative.name} + {purchase.creative_name} - {formatDate( - purchase.actual_date || purchase.scheduled_date - )} + {formatDate(purchase.placement_date)} {formatCurrency(purchase.cost)} @@ -354,11 +326,8 @@ export default function PurchasesPage() { {formatNumber(purchase.subscriptions_count)} - {purchase.cpf ? `₽${formatMetric(purchase.cpf)}` : "—"} - - - {purchase.conversion_rate - ? formatPercent(purchase.conversion_rate) + {purchase.views_count + ? formatNumber(purchase.views_count) : "—"} diff --git a/components/auth/auth-provider.tsx b/components/auth/auth-provider.tsx index 02412d8..88aa87c 100644 --- a/components/auth/auth-provider.tsx +++ b/components/auth/auth-provider.tsx @@ -13,7 +13,7 @@ import React, { } from "react"; import { useRouter } from "next/navigation"; import { authApi } from "@/lib/api"; -import { USE_MOCKS } from "@/lib/utils/constants"; +import { USE_MOCKS, STORAGE_KEYS } from "@/lib/utils/constants"; import type { User } from "@/lib/types/api"; interface AuthContextType { @@ -85,13 +85,28 @@ export const AuthProvider: React.FC = ({ children }) => { setLoading(true); setError(null); - const response = await authApi.complete(token); - setUser(response.user); + // Exchange token for JWT + await authApi.complete(token); + + // Fetch user data with the JWT token + if (!USE_MOCKS) { + const userData = await authApi.me(); + setUser(userData); + + // Save user to storage + if (typeof window !== "undefined") { + localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(userData)); + } + } else { + // For mocks, get user from mock data + const userData = authApi.getUserFromStorage(); + setUser(userData); + } // Redirect to home router.push("/"); } catch (err: any) { - const errorMessage = err?.error?.message || "Ошибка авторизации"; + const errorMessage = err?.detail || err?.error?.message || "Ошибка авторизации"; setError(errorMessage); throw err; } finally { diff --git a/components/layout/app-sidebar.tsx b/components/layout/app-sidebar.tsx index 7615d8c..f7985fb 100644 --- a/components/layout/app-sidebar.tsx +++ b/components/layout/app-sidebar.tsx @@ -143,13 +143,13 @@ export function AppSidebar({ ...props }: React.ComponentProps) { {user && ( )} diff --git a/components/ui/progress.tsx b/components/ui/progress.tsx new file mode 100644 index 0000000..e7a416c --- /dev/null +++ b/components/ui/progress.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib/utils" + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Progress } diff --git a/lib/api/auth.ts b/lib/api/auth.ts index 190ec71..eb804f4 100644 --- a/lib/api/auth.ts +++ b/lib/api/auth.ts @@ -22,22 +22,18 @@ export const authApi = { /** * Complete auth with one-time token from bot */ - complete: async (token: string): Promise => { - const data: AuthCompleteRequest = { token }; - const response = await api.post( - "/auth/complete", - data, + complete: async (token: string): Promise<{ access_token: string }> => { + // GET request with token as query parameter + const response = await api.get<{ access_token: string }>( + `/auth/complete?token=${token}`, { requireAuth: false, } ); - // Save tokens to storage - saveAuthTokens(response.access_token, response.refresh_token); - - // Save user to storage + // Save access token to storage if (typeof window !== "undefined") { - localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(response.user)); + localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, response.access_token); } return response; diff --git a/lib/api/channels.ts b/lib/api/channels.ts index ae5cf1c..de8ab73 100644 --- a/lib/api/channels.ts +++ b/lib/api/channels.ts @@ -5,33 +5,20 @@ import { api } from "./client"; import type { TargetChannel, - TargetChannelDetail, - TargetChannelQueryParams, - TargetChannelUpdateRequest, - ListResponse, + TargetChannelsListResponse, SuccessResponse, } from "@/lib/types/api"; export const channelsApi = { /** * Get list of target channels + * GET /api/v1/target_channels */ - list: (params?: TargetChannelQueryParams) => - api.get>("/target-channels", { params }), - - /** - * Get target channel details - */ - get: (id: string) => api.get(`/target-channels/${id}`), - - /** - * Update target channel (is_active) - */ - update: (id: string, data: TargetChannelUpdateRequest) => - api.patch(`/target-channels/${id}`, data), + list: () => api.get("/target_channels"), /** * Delete (disconnect) target channel + * DELETE /api/v1/target_channels/{channel_id} */ - delete: (id: string) => api.delete(`/target-channels/${id}`), + delete: (id: string) => api.delete(`/target_channels/${id}`), }; diff --git a/lib/api/client.ts b/lib/api/client.ts index 1920758..580e1de 100644 --- a/lib/api/client.ts +++ b/lib/api/client.ts @@ -89,8 +89,14 @@ const routeMockRequest = async ( if (endpoint === "/auth/init") { return mockApiHandlers.authInit(); } - if (endpoint === "/auth/complete") { - return mockApiHandlers.authComplete(body); + if (endpoint.startsWith("/auth/complete")) { + // Extract token from query params + const token = + params.token || + (endpoint.includes("?") + ? new URLSearchParams(endpoint.split("?")[1]).get("token") + : null); + return mockApiHandlers.authComplete({ token }); } if (endpoint === "/auth/refresh") { return mockApiHandlers.authRefresh(body); @@ -100,53 +106,51 @@ const routeMockRequest = async ( } // Target Channels - if (endpoint === "/target-channels" && method === "GET") { - return mockApiHandlers.getTargetChannels(params); + if (endpoint === "/target_channels" && method === "GET") { + return mockApiHandlers.getTargetChannels(); } - if (endpoint.startsWith("/target-channels/") && method === "GET") { - const id = endpoint.split("/")[2]; - return mockApiHandlers.getTargetChannel(id); - } - if (endpoint.startsWith("/target-channels/") && method === "PATCH") { - const id = endpoint.split("/")[2]; - return mockApiHandlers.updateTargetChannel(id, body); - } - if (endpoint.startsWith("/target-channels/") && method === "DELETE") { + if (endpoint.startsWith("/target_channels/") && method === "DELETE") { const id = endpoint.split("/")[2]; return mockApiHandlers.deleteTargetChannel(id); } // External Channels - if (endpoint === "/external-channels" && method === "GET") { - return mockApiHandlers.getExternalChannels(params); + if (endpoint.startsWith("/external_channels/target/") && method === "GET") { + const targetChannelId = endpoint.split("/")[3]; + return mockApiHandlers.getExternalChannels(targetChannelId); } - if (endpoint === "/external-channels" && method === "POST") { + if (endpoint === "/external_channels" && method === "POST") { return mockApiHandlers.createExternalChannel(body); } if ( - endpoint.startsWith("/external-channels/") && - !endpoint.includes("/import") && + endpoint.startsWith("/external_channels/") && + !endpoint.includes("/target/") && + !endpoint.includes("/links") && method === "GET" ) { const id = endpoint.split("/")[2]; return mockApiHandlers.getExternalChannel(id); } - if (endpoint.startsWith("/external-channels/") && method === "PATCH") { + if ( + endpoint.startsWith("/external_channels/") && + endpoint.includes("/links") && + method === "PATCH" + ) { + const id = endpoint.split("/")[2]; + return mockApiHandlers.updateExternalChannelLinks(id, body); + } + if ( + endpoint.startsWith("/external_channels/") && + !endpoint.includes("/links") && + method === "PATCH" + ) { const id = endpoint.split("/")[2]; return mockApiHandlers.updateExternalChannel(id, body); } - if (endpoint.startsWith("/external-channels/") && method === "DELETE") { + if (endpoint.startsWith("/external_channels/") && method === "DELETE") { const id = endpoint.split("/")[2]; return mockApiHandlers.deleteExternalChannel(id); } - if (endpoint === "/external-channels/import" && method === "POST") { - // FormData для импорта - const formData = body as any; - return mockApiHandlers.importExternalChannels( - formData.file, - formData.target_channel_id - ); - } // Creatives if (endpoint === "/creatives" && method === "GET") { @@ -168,28 +172,36 @@ const routeMockRequest = async ( return mockApiHandlers.deleteCreative(id); } - // Purchases - if (endpoint === "/purchases" && method === "GET") { - return mockApiHandlers.getPurchases(params); + // Placements + if (endpoint === "/placements" && method === "GET") { + return mockApiHandlers.getPlacements(params); } - if (endpoint === "/purchases" && method === "POST") { - return mockApiHandlers.createPurchase(body); + if (endpoint === "/placements" && method === "POST") { + return mockApiHandlers.createPlacement(body); } - if (endpoint.match(/^\/purchases\/[^\/]+$/) && method === "GET") { + if (endpoint.match(/^\/placements\/[^\/]+$/) && method === "GET") { const id = endpoint.split("/")[2]; - return mockApiHandlers.getPurchase(id); + return mockApiHandlers.getPlacement(id); } - if (endpoint.match(/^\/purchases\/[^\/]+$/) && method === "PATCH") { + if (endpoint.match(/^\/placements\/[^\/]+$/) && method === "PATCH") { const id = endpoint.split("/")[2]; - return mockApiHandlers.updatePurchase(id, body); + return mockApiHandlers.updatePlacement(id, body); } - if (endpoint.match(/^\/purchases\/[^\/]+$/) && method === "DELETE") { + if (endpoint.match(/^\/placements\/[^\/]+$/) && method === "DELETE") { const id = endpoint.split("/")[2]; - return mockApiHandlers.deletePurchase(id); + return mockApiHandlers.deletePlacement(id); } - if (endpoint.includes("/refresh-views") && method === "POST") { + if (endpoint.includes("/views/fetch") && method === "POST") { const id = endpoint.split("/")[2]; - return mockApiHandlers.refreshPurchaseViews(id); + return mockApiHandlers.fetchPlacementViews(id); + } + if (endpoint.includes("/views/history") && method === "GET") { + const id = endpoint.split("/")[2]; + return mockApiHandlers.getPlacementViewsHistory(id, params); + } + if (endpoint.includes("/views/manual") && method === "POST") { + const id = endpoint.split("/")[2]; + return mockApiHandlers.setPlacementViewsManually(id, params?.views_count); } // Analytics @@ -264,6 +276,27 @@ export const apiRequest = async ( // Handle errors if (!response.ok) { + // FastAPI returns errors in format { "detail": "..." } + // Transform to our ApiError format + if (data.detail) { + throw { + error: { + code: + response.status === 401 + ? "UNAUTHORIZED" + : response.status === 403 + ? "FORBIDDEN" + : response.status === 404 + ? "NOT_FOUND" + : response.status === 422 + ? "VALIDATION_ERROR" + : "API_ERROR", + message: data.detail, + }, + detail: data.detail, + }; + } + // If it's already in our format, throw as is const error: ApiError = data; throw error; } @@ -314,13 +347,7 @@ export const api = { formData: FormData, options?: RequestOptions ): Promise => { - if (USE_MOCKS) { - // Handle mock file upload - const file = formData.get("file") as File; - const targetChannelId = formData.get("target_channel_id") as string; - return mockApiHandlers.importExternalChannels(file, targetChannelId) as T; - } - + // File import is now done on client side, no need for mock handling const url = `${API_URL}${endpoint}`; const token = getAuthToken(); diff --git a/lib/api/creatives.ts b/lib/api/creatives.ts index 36138e1..2d9e56c 100644 --- a/lib/api/creatives.ts +++ b/lib/api/creatives.ts @@ -5,11 +5,10 @@ import { api } from "./client"; import type { Creative, - CreativeDetail, CreativeQueryParams, CreativeCreateRequest, CreativeUpdateRequest, - ListResponse, + CreativesListResponse, SuccessResponse, } from "@/lib/types/api"; @@ -18,12 +17,12 @@ export const creativesApi = { * Get list of creatives */ list: (params?: CreativeQueryParams) => - api.get>("/creatives", { params }), + api.get("/creatives", { params }), /** * Get creative details */ - get: (id: string) => api.get(`/creatives/${id}`), + get: (id: string) => api.get(`/creatives/${id}`), /** * Create new creative diff --git a/lib/api/external-channels.ts b/lib/api/external-channels.ts index 817392a..4b0818b 100644 --- a/lib/api/external-channels.ts +++ b/lib/api/external-channels.ts @@ -5,57 +5,48 @@ import { api } from "./client"; import type { ExternalChannel, - ExternalChannelDetail, - ExternalChannelQueryParams, + ExternalChannelsListResponse, ExternalChannelCreateRequest, ExternalChannelUpdateRequest, - ExternalChannelImportResponse, - ListResponse, + ExternalChannelLinksUpdateRequest, SuccessResponse, } from "@/lib/types/api"; export const externalChannelsApi = { /** - * Get list of external channels + * Get list of external channels for target channel + * GET /api/v1/external_channels/target/{target_channel_id} */ - list: (params?: ExternalChannelQueryParams) => - api.get>("/external-channels", { params }), - - /** - * Get external channel details - */ - get: (id: string) => - api.get(`/external-channels/${id}`), + list: (targetChannelId: string) => + api.get( + `/external_channels/target/${targetChannelId}` + ), /** * Create new external channel + * POST /api/v1/external_channels */ create: (data: ExternalChannelCreateRequest) => - api.post("/external-channels", data), + api.post("/external_channels", data), /** * Update external channel + * PATCH /api/v1/external_channels/{channel_id} */ update: (id: string, data: ExternalChannelUpdateRequest) => - api.patch(`/external-channels/${id}`, data), + api.patch(`/external_channels/${id}`, data), + + /** + * Update external channel links + * PATCH /api/v1/external_channels/{channel_id}/links + */ + updateLinks: (id: string, data: ExternalChannelLinksUpdateRequest) => + api.patch(`/external_channels/${id}/links`, data), /** * Delete external channel + * DELETE /api/v1/external_channels/{channel_id} */ delete: (id: string) => - api.delete(`/external-channels/${id}`), - - /** - * Import external channels from Excel file - */ - import: (file: File, targetChannelId: string) => { - const formData = new FormData(); - formData.append("file", file); - formData.append("target_channel_id", targetChannelId); - - return api.upload( - "/external-channels/import", - formData - ); - }, + api.delete(`/external_channels/${id}`), }; diff --git a/lib/api/index.ts b/lib/api/index.ts index 4509722..ef0f194 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -7,5 +7,5 @@ export * from "./auth"; export * from "./channels"; export * from "./external-channels"; export * from "./creatives"; -export * from "./purchases"; +export * from "./placements"; export * from "./analytics"; diff --git a/lib/api/placements.ts b/lib/api/placements.ts new file mode 100644 index 0000000..8a88c16 --- /dev/null +++ b/lib/api/placements.ts @@ -0,0 +1,73 @@ +// ============================================================================ +// Placements API +// ============================================================================ + +import { api } from "./client"; +import type { + Placement, + PlacementQueryParams, + PlacementCreateRequest, + PlacementUpdateRequest, + PlacementsListResponse, + ViewsFetchResponse, + ViewsHistoryResponse, + SuccessResponse, +} from "@/lib/types/api"; + +export const placementsApi = { + /** + * Get list of placements + */ + list: (params?: PlacementQueryParams) => + api.get("/placements", { params }), + + /** + * Get placement details + */ + get: (id: string) => api.get(`/placements/${id}`), + + /** + * Create new placement + */ + create: (data: PlacementCreateRequest) => + api.post("/placements", data), + + /** + * Update placement + */ + update: (id: string, data: PlacementUpdateRequest) => + api.patch(`/placements/${id}`, data), + + /** + * Delete placement + */ + delete: (id: string) => api.delete(`/placements/${id}`), + + /** + * Fetch views for placement + */ + fetchViews: (id: string) => + api.post(`/placements/${id}/views/fetch`), + + /** + * Get views history for placement + */ + viewsHistory: ( + id: string, + params?: { from_date?: string; to_date?: string } + ) => + api.get(`/placements/${id}/views/history`, { + params, + }), + + /** + * Set views manually + */ + setViewsManually: (id: string, views_count: number) => + api.post(`/placements/${id}/views/manual`, null, { + params: { views_count }, + }), +}; + +// Keep purchasesApi as an alias for backwards compatibility +export const purchasesApi = placementsApi; diff --git a/lib/api/purchases.ts b/lib/api/purchases.ts deleted file mode 100644 index a5a01ee..0000000 --- a/lib/api/purchases.ts +++ /dev/null @@ -1,52 +0,0 @@ -// ============================================================================ -// Purchases API -// ============================================================================ - -import { api } from "./client"; -import type { - Purchase, - PurchaseDetail, - PurchaseQueryParams, - PurchaseCreateRequest, - PurchaseCreateResponse, - PurchaseUpdateRequest, - PurchaseRefreshViewsResponse, - ListResponse, - SuccessResponse, -} from "@/lib/types/api"; - -export const purchasesApi = { - /** - * Get list of purchases - */ - list: (params?: PurchaseQueryParams) => - api.get>("/purchases", { params }), - - /** - * Get purchase details - */ - get: (id: string) => api.get(`/purchases/${id}`), - - /** - * Create new purchase - */ - create: (data: PurchaseCreateRequest) => - api.post("/purchases", data), - - /** - * Update purchase - */ - update: (id: string, data: PurchaseUpdateRequest) => - api.patch(`/purchases/${id}`, data), - - /** - * Delete purchase - */ - delete: (id: string) => api.delete(`/purchases/${id}`), - - /** - * Refresh views count for purchase - */ - refreshViews: (id: string) => - api.post(`/purchases/${id}/refresh-views`), -}; diff --git a/lib/mocks/data/channels.ts b/lib/mocks/data/channels.ts index 24d232a..e490520 100644 --- a/lib/mocks/data/channels.ts +++ b/lib/mocks/data/channels.ts @@ -1,4 +1,4 @@ -import { TargetChannel, TargetChannelDetail } from "@/lib/types/api"; +import { TargetChannel } from "@/lib/types/api"; // ============================================================================ // Mock Target Channels Data @@ -6,74 +6,24 @@ import { TargetChannel, TargetChannelDetail } from "@/lib/types/api"; export const mockTargetChannels: TargetChannel[] = [ { - id: "tc1", + id: "550e8400-e29b-41d4-a716-446655440010", telegram_id: -1001234567890, title: "Мой Главный Канал", username: "my_main_channel", - description: "Основной канал для продвижения продукта", is_active: true, - created_at: "2024-01-10T10:00:00.000Z", - updated_at: "2024-01-10T10:00:00.000Z", - total_purchases: 15, - total_subscriptions: 1250, - avg_cpf: 45.5, }, { - id: "tc2", + id: "550e8400-e29b-41d4-a716-446655440011", telegram_id: -1009876543210, title: "Тестовый Канал", username: "test_channel_promo", - description: "Канал для тестирования рекламных креативов", is_active: true, - created_at: "2024-02-15T12:00:00.000Z", - updated_at: "2024-02-15T12:00:00.000Z", - total_purchases: 8, - total_subscriptions: 450, - avg_cpf: 52.3, }, { - id: "tc3", + id: "550e8400-e29b-41d4-a716-446655440012", telegram_id: -1005555555555, title: "Новости и Обновления", username: null, - description: "Приватный канал для новостей", is_active: false, - created_at: "2024-03-01T08:00:00.000Z", - updated_at: "2024-03-20T14:30:00.000Z", - total_purchases: 3, - total_subscriptions: 120, - avg_cpf: 67.8, }, ]; - -export const getMockTargetChannelDetail = ( - id: string -): TargetChannelDetail | null => { - const channel = mockTargetChannels.find((c) => c.id === id); - if (!channel) return null; - - return { - ...channel, - recent_purchases: [], // Will be populated from purchases mock - stats_by_period: [ - { - period: "day", - subscriptions: 25, - cost: 1200, - cpf: 48.0, - }, - { - period: "week", - subscriptions: 180, - cost: 8500, - cpf: 47.2, - }, - { - period: "month", - subscriptions: 750, - cost: 35000, - cpf: 46.7, - }, - ], - }; -}; diff --git a/lib/mocks/data/creatives.ts b/lib/mocks/data/creatives.ts index 13d27a8..44d4e56 100644 --- a/lib/mocks/data/creatives.ts +++ b/lib/mocks/data/creatives.ts @@ -1,4 +1,4 @@ -import { Creative, CreativeDetail } from "@/lib/types/api"; +import { Creative } from "@/lib/types/api"; import { mockTargetChannels } from "./channels"; // ============================================================================ @@ -11,61 +11,39 @@ export const mockCreatives: Creative[] = [ name: 'Креатив "Присоединяйся"', text: "🔥 Присоединяйся к нашему сообществу!\n\nУзнавай первым о новых возможностях и фишках.\n\n👉 {link}", target_channel_id: "tc1", - is_archived: false, + target_channel_title: mockTargetChannels[0].title, created_at: "2024-01-15T10:00:00.000Z", - updated_at: "2024-01-15T10:00:00.000Z", - target_channel: mockTargetChannels[0], - total_purchases: 10, - total_subscriptions: 850, - avg_cpf: 42.3, + status: "active", + placements_count: 10, }, { id: "cr2", name: 'Креатив "Эксклюзив"', text: "⭐ Эксклюзивный контент только для подписчиков!\n\nНе упусти возможность быть в курсе всех трендов.\n\nПереходи: {link}", target_channel_id: "tc1", - is_archived: false, + target_channel_title: mockTargetChannels[0].title, created_at: "2024-01-20T12:00:00.000Z", - updated_at: "2024-01-20T12:00:00.000Z", - target_channel: mockTargetChannels[0], - total_purchases: 5, - total_subscriptions: 400, - avg_cpf: 48.5, + status: "active", + placements_count: 5, }, { id: "cr3", name: 'Креатив "Обучение"', text: "📚 Хочешь научиться зарабатывать больше?\n\nПодписывайся на канал с проверенными методиками!\n\n{link}", target_channel_id: "tc2", - is_archived: false, + target_channel_title: mockTargetChannels[1].title, created_at: "2024-02-05T09:00:00.000Z", - updated_at: "2024-02-05T09:00:00.000Z", - target_channel: mockTargetChannels[1], - total_purchases: 8, - total_subscriptions: 450, - avg_cpf: 52.3, + status: "active", + placements_count: 8, }, { id: "cr4", name: "Старый креатив (архив)", text: "Устаревший текст с предложением. {link}", target_channel_id: "tc1", - is_archived: true, + target_channel_title: mockTargetChannels[0].title, created_at: "2023-12-01T10:00:00.000Z", - updated_at: "2024-01-10T15:00:00.000Z", - target_channel: mockTargetChannels[0], - total_purchases: 2, - total_subscriptions: 80, - avg_cpf: 75.0, + status: "archived", + placements_count: 2, }, ]; - -export const getMockCreativeDetail = (id: string): CreativeDetail | null => { - const creative = mockCreatives.find((c) => c.id === id); - if (!creative) return null; - - return { - ...creative, - purchases: [], // Will be populated from purchases mock - }; -}; diff --git a/lib/mocks/data/external-channels.ts b/lib/mocks/data/external-channels.ts index 8763afd..7feb534 100644 --- a/lib/mocks/data/external-channels.ts +++ b/lib/mocks/data/external-channels.ts @@ -1,4 +1,4 @@ -import { ExternalChannel, ExternalChannelDetail } from "@/lib/types/api"; +import { ExternalChannel } from "@/lib/types/api"; // ============================================================================ // Mock External Channels Data @@ -6,90 +6,43 @@ import { ExternalChannel, ExternalChannelDetail } from "@/lib/types/api"; export const mockExternalChannels: ExternalChannel[] = [ { - id: "ec1", + id: "550e8400-e29b-41d4-a716-446655440020", telegram_id: -1001111111111, title: "Канал о Маркетинге", username: "marketing_pro", - link: "https://t.me/marketing_pro", subscribers_count: 50000, description: "Лучшие практики маркетинга", - created_at: "2024-01-05T10:00:00.000Z", - updated_at: "2024-01-05T10:00:00.000Z", - target_channels: ["tc1", "tc2"], - total_purchases: 5, - avg_cpf: 42.5, - avg_cpm: 850.0, }, { - id: "ec2", + id: "550e8400-e29b-41d4-a716-446655440021", telegram_id: -1002222222222, title: "Бизнес и Стартапы", username: "business_startups", - link: "https://t.me/business_startups", subscribers_count: 120000, description: "Новости мира бизнеса", - created_at: "2024-01-08T11:00:00.000Z", - updated_at: "2024-01-08T11:00:00.000Z", - target_channels: ["tc1"], - total_purchases: 8, - avg_cpf: 55.8, - avg_cpm: 1200.0, }, { - id: "ec3", + id: "550e8400-e29b-41d4-a716-446655440022", telegram_id: -1003333333333, title: "IT и Технологии", username: "it_tech_news", - link: "https://t.me/it_tech_news", subscribers_count: 85000, description: "Все о новых технологиях", - created_at: "2024-01-12T09:00:00.000Z", - updated_at: "2024-01-12T09:00:00.000Z", - target_channels: ["tc1", "tc2"], - total_purchases: 3, - avg_cpf: 38.2, - avg_cpm: 750.0, }, { - id: "ec4", - telegram_id: null, + id: "550e8400-e29b-41d4-a716-446655440023", + telegram_id: -1004444444444, title: "Крипто Инвестиции", username: "crypto_invest", - link: "https://t.me/crypto_invest", subscribers_count: 200000, description: null, - created_at: "2024-02-01T10:00:00.000Z", - updated_at: "2024-02-01T10:00:00.000Z", - target_channels: ["tc2"], - total_purchases: 2, - avg_cpf: 68.5, - avg_cpm: 1500.0, }, { - id: "ec5", + id: "550e8400-e29b-41d4-a716-446655440024", telegram_id: -1005555555555, title: "Продажи и Переговоры", username: "sales_expert", - link: "https://t.me/sales_expert", subscribers_count: 35000, description: "Экспертиза в продажах", - created_at: "2024-02-10T14:00:00.000Z", - updated_at: "2024-02-10T14:00:00.000Z", - target_channels: ["tc1"], - total_purchases: 4, - avg_cpf: 45.0, - avg_cpm: 900.0, }, ]; - -export const getMockExternalChannelDetail = ( - id: string -): ExternalChannelDetail | null => { - const channel = mockExternalChannels.find((c) => c.id === id); - if (!channel) return null; - - return { - ...channel, - purchases: [], // Will be populated from purchases mock - }; -}; diff --git a/lib/mocks/data/placements.ts b/lib/mocks/data/placements.ts new file mode 100644 index 0000000..9322e07 --- /dev/null +++ b/lib/mocks/data/placements.ts @@ -0,0 +1,213 @@ +import { Placement, Subscription, ViewsSnapshot } from "@/lib/types/api"; +import { mockTargetChannels } from "./channels"; +import { mockExternalChannels } from "./external-channels"; +import { mockCreatives } from "./creatives"; + +// ============================================================================ +// Mock Placements Data +// ============================================================================ + +export const mockPlacements: Placement[] = [ + { + id: "p1", + target_channel_id: "tc1", + target_channel_title: mockTargetChannels[0].title, + external_channel_id: "ec1", + external_channel_title: mockExternalChannels[0].title, + creative_id: "cr1", + creative_name: mockCreatives[0].name, + placement_date: "2024-03-15T10:00:00.000Z", + cost: 5000, + comment: "Тестовая закупка на утро", + ad_post_url: "https://t.me/marketing_pro/1234", + invite_link_type: "approval", + invite_link: "https://t.me/+AbCdEfGhIjKlMnOp", + status: "active", + subscriptions_count: 125, + views_count: 12500, + views_availability: "available", + last_views_fetch_at: "2024-03-16T10:00:00.000Z", + created_at: "2024-03-14T15:00:00.000Z", + }, + { + id: "p2", + target_channel_id: "tc1", + target_channel_title: mockTargetChannels[0].title, + external_channel_id: "ec2", + external_channel_title: mockExternalChannels[1].title, + creative_id: "cr2", + creative_name: mockCreatives[1].name, + placement_date: "2024-03-18T14:00:00.000Z", + cost: 12000, + comment: "Большой канал, ожидаем хороших результатов", + ad_post_url: "https://t.me/business_startups/5678", + invite_link_type: "approval", + invite_link: "https://t.me/+QrStUvWxYzAbCdEf", + status: "active", + subscriptions_count: 210, + views_count: 28000, + views_availability: "available", + last_views_fetch_at: "2024-03-19T10:00:00.000Z", + created_at: "2024-03-17T10:00:00.000Z", + }, + { + id: "p3", + target_channel_id: "tc2", + target_channel_title: mockTargetChannels[1].title, + external_channel_id: "ec3", + external_channel_title: mockExternalChannels[2].title, + creative_id: "cr3", + creative_name: mockCreatives[2].name, + placement_date: "2024-03-20T16:00:00.000Z", + cost: 3500, + comment: null, + ad_post_url: "https://t.me/it_tech_news/9101", + invite_link_type: "public", + invite_link: "https://t.me/+GhIjKlMnOpQrStUv", + status: "active", + subscriptions_count: 75, + views_count: 8500, + views_availability: "available", + last_views_fetch_at: "2024-03-21T10:00:00.000Z", + created_at: "2024-03-19T12:00:00.000Z", + }, + { + id: "p4", + target_channel_id: "tc1", + target_channel_title: mockTargetChannels[0].title, + external_channel_id: "ec1", + external_channel_title: mockExternalChannels[0].title, + creative_id: "cr1", + creative_name: mockCreatives[0].name, + placement_date: "2024-03-22T08:00:00.000Z", + cost: 4500, + comment: "Повтор в этом же канале", + ad_post_url: "https://t.me/marketing_pro/1290", + invite_link_type: "approval", + invite_link: "https://t.me/+XyZaBcDeFgHiJkLm", + status: "active", + subscriptions_count: 95, + views_count: null, + views_availability: "unavailable", + last_views_fetch_at: "2024-03-23T09:00:00.000Z", + created_at: "2024-03-21T14:00:00.000Z", + }, + { + id: "p5", + target_channel_id: "tc1", + target_channel_title: mockTargetChannels[0].title, + external_channel_id: "ec2", + external_channel_title: mockExternalChannels[1].title, + creative_id: "cr1", + creative_name: mockCreatives[0].name, + placement_date: "2024-02-10T10:00:00.000Z", + cost: 10000, + comment: "Старая закупка (архив)", + ad_post_url: "https://t.me/business_startups/4567", + invite_link_type: "public", + invite_link: "https://t.me/+OldLinkArch12345", + status: "archived", + subscriptions_count: 150, + views_count: 20000, + views_availability: "available", + last_views_fetch_at: "2024-02-12T10:00:00.000Z", + created_at: "2024-02-09T10:00:00.000Z", + }, +]; + +// Keep old name for backward compatibility +export const mockPurchases = mockPlacements; + +// ============================================================================ +// Mock Subscriptions +// ============================================================================ + +export const mockSubscriptions: Subscription[] = [ + { + id: "s1", + purchase_id: "p1", + telegram_user_id: 123456789, + user_first_name: "Иван", + user_last_name: "Иванов", + user_username: "ivan_ivanov", + subscribed_at: "2024-03-15T11:35:00.000Z", + is_active: true, + event_type: "join_request", + }, + { + id: "s2", + purchase_id: "p1", + telegram_user_id: 987654321, + user_first_name: "Мария", + user_last_name: null, + user_username: "maria123", + subscribed_at: "2024-03-15T12:05:00.000Z", + is_active: true, + event_type: "join_request", + }, + { + id: "s3", + purchase_id: "p1", + telegram_user_id: 456789123, + user_first_name: "Алексей", + user_last_name: "Петров", + user_username: null, + subscribed_at: "2024-03-15T13:20:00.000Z", + is_active: false, + event_type: "join_request", + }, + { + id: "s4", + purchase_id: "p2", + telegram_user_id: 789123456, + user_first_name: "Елена", + user_last_name: "Сидорова", + user_username: "elena_s", + subscribed_at: "2024-03-18T14:30:00.000Z", + is_active: true, + event_type: "chat_member", + }, +]; + +// ============================================================================ +// Mock Views History +// ============================================================================ + +export const mockViewsHistory: ViewsSnapshot[] = [ + { + id: "v1", + purchase_id: "p1", + views: 10000, + fetched_at: "2024-03-15T12:00:00.000Z", + }, + { + id: "v2", + purchase_id: "p1", + views: 11500, + fetched_at: "2024-03-15T18:00:00.000Z", + }, + { + id: "v3", + purchase_id: "p1", + views: 12500, + fetched_at: "2024-03-16T10:00:00.000Z", + }, + { + id: "v4", + purchase_id: "p2", + views: 25000, + fetched_at: "2024-03-18T15:00:00.000Z", + }, + { + id: "v5", + purchase_id: "p2", + views: 27000, + fetched_at: "2024-03-18T20:00:00.000Z", + }, + { + id: "v6", + purchase_id: "p2", + views: 28000, + fetched_at: "2024-03-19T10:00:00.000Z", + }, +]; diff --git a/lib/mocks/data/purchases.ts b/lib/mocks/data/purchases.ts deleted file mode 100644 index afcbf94..0000000 --- a/lib/mocks/data/purchases.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { - Purchase, - PurchaseDetail, - Subscription, - ViewsSnapshot, -} from "@/lib/types/api"; -import { mockTargetChannels } from "./channels"; -import { mockExternalChannels } from "./external-channels"; -import { mockCreatives } from "./creatives"; - -// ============================================================================ -// Mock Purchases Data -// ============================================================================ - -export const mockPurchases: Purchase[] = [ - { - id: "p1", - target_channel_id: "tc1", - external_channel_id: "ec1", - creative_id: "cr1", - scheduled_date: "2024-03-15T10:00:00.000Z", - actual_date: "2024-03-15T11:30:00.000Z", - cost: 5000, - comment: "Тестовая закупка на утро", - post_link: "https://t.me/marketing_pro/1234", - invite_link: "https://t.me/+AbCdEfGhIjKlMnOp", - invite_link_type: "public", - is_archived: false, - created_at: "2024-03-14T15:00:00.000Z", - updated_at: "2024-03-15T11:30:00.000Z", - target_channel: mockTargetChannels[0], - external_channel: mockExternalChannels[0], - creative: mockCreatives[0], - subscriptions_count: 125, - views_count: 12500, - cpf: 40.0, - cpm: 400.0, - conversion_rate: 1.0, - }, - { - id: "p2", - target_channel_id: "tc1", - external_channel_id: "ec2", - creative_id: "cr2", - scheduled_date: "2024-03-18T14:00:00.000Z", - actual_date: "2024-03-18T14:15:00.000Z", - cost: 12000, - comment: "Большой канал, ожидаем хороших результатов", - post_link: "https://t.me/business_startups/5678", - invite_link: "https://t.me/+QrStUvWxYzAbCdEf", - invite_link_type: "public", - is_archived: false, - created_at: "2024-03-17T10:00:00.000Z", - updated_at: "2024-03-18T14:15:00.000Z", - target_channel: mockTargetChannels[0], - external_channel: mockExternalChannels[1], - creative: mockCreatives[1], - subscriptions_count: 210, - views_count: 28000, - cpf: 57.1, - cpm: 428.6, - conversion_rate: 0.75, - }, - { - id: "p3", - target_channel_id: "tc2", - external_channel_id: "ec3", - creative_id: "cr3", - scheduled_date: "2024-03-20T16:00:00.000Z", - actual_date: null, - cost: 3500, - comment: null, - post_link: "https://t.me/it_tech_news/9101", - invite_link: "https://t.me/+GhIjKlMnOpQrStUv", - invite_link_type: "private", - is_archived: false, - created_at: "2024-03-19T12:00:00.000Z", - updated_at: "2024-03-19T12:00:00.000Z", - target_channel: mockTargetChannels[1], - external_channel: mockExternalChannels[2], - creative: mockCreatives[2], - subscriptions_count: 85, - views_count: 8500, - cpf: 41.2, - cpm: 411.8, - conversion_rate: 1.0, - }, - { - id: "p4", - target_channel_id: "tc1", - external_channel_id: "ec5", - creative_id: "cr1", - scheduled_date: "2024-03-22T12:00:00.000Z", - actual_date: null, - cost: 4500, - comment: "Повторная закупка в том же канале", - post_link: null, - invite_link: "https://t.me/+WxYzAbCdEfGhIjKl", - invite_link_type: "public", - is_archived: false, - created_at: "2024-03-21T09:00:00.000Z", - updated_at: "2024-03-21T09:00:00.000Z", - target_channel: mockTargetChannels[0], - external_channel: mockExternalChannels[4], - creative: mockCreatives[0], - subscriptions_count: 95, - views_count: null, - cpf: 47.4, - cpm: null, - conversion_rate: null, - }, - { - id: "p5", - target_channel_id: "tc1", - external_channel_id: "ec1", - creative_id: "cr1", - scheduled_date: "2024-02-10T10:00:00.000Z", - actual_date: "2024-02-10T10:30:00.000Z", - cost: 5500, - comment: "Старая закупка для истории", - post_link: "https://t.me/marketing_pro/1100", - invite_link: "https://t.me/+OldInviteLink123", - invite_link_type: "public", - is_archived: true, - created_at: "2024-02-09T15:00:00.000Z", - updated_at: "2024-02-20T10:00:00.000Z", - target_channel: mockTargetChannels[0], - external_channel: mockExternalChannels[0], - creative: mockCreatives[0], - subscriptions_count: 140, - views_count: 15000, - cpf: 39.3, - cpm: 366.7, - conversion_rate: 0.93, - }, -]; - -// Mock Subscriptions -export const mockSubscriptions: Subscription[] = [ - { - id: "s1", - purchase_id: "p1", - telegram_user_id: 111111111, - user_first_name: "Иван", - user_last_name: "Петров", - user_username: "user1", - subscribed_at: "2024-03-15T11:35:00.000Z", - is_active: true, - event_type: "chat_member", - }, - { - id: "s2", - purchase_id: "p1", - telegram_user_id: 222222222, - user_first_name: "Мария", - user_last_name: "Сидорова", - user_username: "user2", - subscribed_at: "2024-03-15T11:40:00.000Z", - is_active: true, - event_type: "chat_member", - }, - { - id: "s3", - purchase_id: "p1", - telegram_user_id: 333333333, - user_first_name: "Алексей", - user_last_name: null, - user_username: null, - subscribed_at: "2024-03-15T12:00:00.000Z", - is_active: false, - event_type: "join_request", - }, - { - id: "s4", - purchase_id: "p2", - telegram_user_id: 444444444, - user_first_name: "Елена", - user_last_name: "Кузнецова", - user_username: "user4", - subscribed_at: "2024-03-18T14:20:00.000Z", - is_active: true, - event_type: "chat_member", - }, - { - id: "s5", - purchase_id: "p2", - telegram_user_id: 555555555, - user_first_name: "Дмитрий", - user_last_name: "Смирнов", - user_username: "user5", - subscribed_at: "2024-03-18T14:25:00.000Z", - is_active: true, - event_type: "chat_member", - }, -]; - -// Mock Views Snapshots -export const mockViewsSnapshots: ViewsSnapshot[] = [ - { - id: "v1", - purchase_id: "p1", - views: 5000, - fetched_at: "2024-03-15T12:00:00.000Z", - }, - { - id: "v2", - purchase_id: "p1", - views: 8500, - fetched_at: "2024-03-15T18:00:00.000Z", - }, - { - id: "v3", - purchase_id: "p1", - views: 10200, - fetched_at: "2024-03-16T09:00:00.000Z", - }, - { - id: "v4", - purchase_id: "p1", - views: 11800, - fetched_at: "2024-03-16T18:00:00.000Z", - }, - { - id: "v5", - purchase_id: "p1", - views: 12500, - fetched_at: "2024-03-17T12:00:00.000Z", - }, - { - id: "v6", - purchase_id: "p2", - views: 15000, - fetched_at: "2024-03-18T15:00:00.000Z", - }, - { - id: "v7", - purchase_id: "p2", - views: 22000, - fetched_at: "2024-03-18T20:00:00.000Z", - }, - { - id: "v8", - purchase_id: "p2", - views: 26500, - fetched_at: "2024-03-19T09:00:00.000Z", - }, - { - id: "v9", - purchase_id: "p2", - views: 28000, - fetched_at: "2024-03-19T18:00:00.000Z", - }, -]; - -export const getMockPurchaseDetail = (id: string): PurchaseDetail | null => { - const purchase = mockPurchases.find((p) => p.id === id); - if (!purchase) return null; - - return { - ...purchase, - subscriptions: mockSubscriptions.filter((s) => s.purchase_id === id), - views_history: mockViewsSnapshots.filter((v) => v.purchase_id === id), - }; -}; diff --git a/lib/mocks/data/users.ts b/lib/mocks/data/users.ts index eeb3d74..f8f0aa4 100644 --- a/lib/mocks/data/users.ts +++ b/lib/mocks/data/users.ts @@ -6,20 +6,14 @@ import { User } from "@/lib/types/api"; export const mockUsers: User[] = [ { - id: "1", + id: "550e8400-e29b-41d4-a716-446655440001", telegram_id: 123456789, username: "ivan_test", - first_name: "Иван", - last_name: "Иванов", - created_at: "2024-01-15T10:00:00.000Z", }, { - id: "2", + id: "550e8400-e29b-41d4-a716-446655440002", telegram_id: 987654321, username: "maria_test", - first_name: "Мария", - last_name: "Петрова", - created_at: "2024-02-01T10:00:00.000Z", }, ]; diff --git a/lib/mocks/handlers.ts b/lib/mocks/handlers.ts index 9a3b844..2277b7b 100644 --- a/lib/mocks/handlers.ts +++ b/lib/mocks/handlers.ts @@ -6,13 +6,11 @@ import { mockCurrentUser, mockTokens, mockTargetChannels, - getMockTargetChannelDetail, mockExternalChannels, - getMockExternalChannelDetail, mockCreatives, - getMockCreativeDetail, - mockPurchases, - getMockPurchaseDetail, + mockPlacements, + mockSubscriptions, + mockViewsHistory, mockAnalyticsOverview, mockCostsReportByDay, mockCostsReportByWeek, @@ -28,22 +26,21 @@ import type { TargetChannel, ExternalChannel, Creative, - Purchase, - PurchaseCreateRequest, - PurchaseCreateResponse, + Placement, + PlacementCreateRequest, + PlacementUpdateRequest, CreativeCreateRequest, ExternalChannelCreateRequest, - PurchaseRefreshViewsResponse, - ExternalChannelImportResponse, + ViewsFetchResponse, + ViewsHistoryResponse, AuthCompleteRequest, AuthCompleteResponse, AuthRefreshRequest, AuthRefreshResponse, AuthInitResponse, - TargetChannelUpdateRequest, ExternalChannelUpdateRequest, CreativeUpdateRequest, - PurchaseUpdateRequest, + ExternalChannelsListResponse, } from "@/lib/types/api"; // Simulate network delay @@ -75,15 +72,21 @@ export const mockApiHandlers = { }; }, - async authComplete(data: AuthCompleteRequest): Promise { + async authComplete( + data: AuthCompleteRequest | { token: string | null } + ): Promise<{ access_token: string }> { await delay(); if (!data.token) { throw mockError("VALIDATION_ERROR", "Token is required"); } + + // Save mock user to localStorage for later retrieval + if (typeof window !== "undefined") { + localStorage.setItem("tgex_user", JSON.stringify(mockCurrentUser)); + } + return { access_token: mockTokens.access_token, - refresh_token: mockTokens.refresh_token, - user: mockCurrentUser, }; }, @@ -105,44 +108,10 @@ export const mockApiHandlers = { // -------------------------------------------------------------------------- // Target Channels // -------------------------------------------------------------------------- - async getTargetChannels(params?: { - is_active?: boolean; - }): Promise> { + async getTargetChannels() { await delay(); - let filtered = [...mockTargetChannels]; - - if (params?.is_active !== undefined) { - filtered = filtered.filter((c) => c.is_active === params.is_active); - } - return { - data: filtered, - total: filtered.length, - }; - }, - - async getTargetChannel(id: string) { - await delay(); - const detail = getMockTargetChannelDetail(id); - if (!detail) { - throw mockError("NOT_FOUND", "Target channel not found"); - } - return detail; - }, - - async updateTargetChannel( - id: string, - data: TargetChannelUpdateRequest - ): Promise { - await delay(); - const channel = mockTargetChannels.find((c) => c.id === id); - if (!channel) { - throw mockError("NOT_FOUND", "Target channel not found"); - } - return { - ...channel, - is_active: data.is_active, - updated_at: new Date().toISOString(), + target_channels: mockTargetChannels, }; }, @@ -158,41 +127,24 @@ export const mockApiHandlers = { // -------------------------------------------------------------------------- // External Channels // -------------------------------------------------------------------------- - async getExternalChannels(params?: { - target_channel_id?: string; - search?: string; - }): Promise> { + async getExternalChannels( + targetChannelId: string + ): Promise { await delay(); - let filtered = [...mockExternalChannels]; - - if (params?.target_channel_id) { - filtered = filtered.filter((c) => - c.target_channels.includes(params.target_channel_id!) - ); - } - - if (params?.search) { - const search = params.search.toLowerCase(); - filtered = filtered.filter( - (c) => - c.title.toLowerCase().includes(search) || - c.username?.toLowerCase().includes(search) - ); - } - + // Возвращаем все каналы для указанного target channel + // В реальном API это будет фильтрация по связям через промежуточную таблицу return { - data: filtered, - total: filtered.length, + external_channels: mockExternalChannels, }; }, - async getExternalChannel(id: string) { + async getExternalChannel(id: string): Promise { await delay(); - const detail = getMockExternalChannelDetail(id); - if (!detail) { + const channel = mockExternalChannels.find((c) => c.id === id); + if (!channel) { throw mockError("NOT_FOUND", "External channel not found"); } - return detail; + return channel; }, async createExternalChannel( @@ -201,18 +153,11 @@ export const mockApiHandlers = { await delay(500); const newChannel: ExternalChannel = { id: generateId(), - telegram_id: null, + telegram_id: data.telegram_id, title: data.title, username: data.username || null, - link: data.link, subscribers_count: data.subscribers_count || null, description: data.description || null, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - target_channels: data.target_channel_ids, - total_purchases: 0, - avg_cpf: null, - avg_cpm: null, }; mockExternalChannels.push(newChannel); return newChannel; @@ -230,7 +175,6 @@ export const mockApiHandlers = { return { ...channel, ...data, - updated_at: new Date().toISOString(), }; }, @@ -244,42 +188,20 @@ export const mockApiHandlers = { return { success: true }; }, - async importExternalChannels( - file: File, - targetChannelId: string - ): Promise { - await delay(1500); // Longer delay for file processing - - // Mock: simulate importing 3-5 channels - const importCount = Math.floor(Math.random() * 3) + 3; - const imported: ExternalChannel[] = []; - - for (let i = 0; i < importCount; i++) { - const newChannel: ExternalChannel = { - id: generateId(), - telegram_id: null, - title: `Импортированный канал ${i + 1}`, - username: `imported_channel_${i + 1}`, - link: `https://t.me/imported_channel_${i + 1}`, - subscribers_count: Math.floor(Math.random() * 100000) + 10000, - description: "Импортировано из Excel", - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - target_channels: [targetChannelId], - total_purchases: 0, - avg_cpf: null, - avg_cpm: null, - }; - imported.push(newChannel); - mockExternalChannels.push(newChannel); + async updateExternalChannelLinks( + id: string, + data: { + add_target_channel_ids?: string[]; + remove_target_channel_ids?: string[]; } - - return { - imported: importCount, - skipped: 1, - errors: ["Строка 15: некорректный формат ссылки"], - channels: imported, - }; + ): Promise { + await delay(); + const channel = mockExternalChannels.find((c) => c.id === id); + if (!channel) { + throw mockError("NOT_FOUND", "External channel not found"); + } + // В упрощенной версии просто возвращаем канал + return channel; }, // -------------------------------------------------------------------------- @@ -287,8 +209,8 @@ export const mockApiHandlers = { // -------------------------------------------------------------------------- async getCreatives(params?: { target_channel_id?: string; - is_archived?: boolean; - }): Promise> { + include_archived?: boolean; + }) { await delay(); let filtered = [...mockCreatives]; @@ -298,36 +220,27 @@ export const mockApiHandlers = { ); } - if (params?.is_archived !== undefined) { - filtered = filtered.filter((c) => c.is_archived === params.is_archived); + if (!params?.include_archived) { + filtered = filtered.filter((c) => c.status !== "archived"); } return { - data: filtered, - total: filtered.length, + creatives: filtered, }; }, - async getCreative(id: string) { + async getCreative(id: string): Promise { await delay(); - const detail = getMockCreativeDetail(id); - if (!detail) { + const creative = mockCreatives.find((c) => c.id === id); + if (!creative) { throw mockError("NOT_FOUND", "Creative not found"); } - return detail; + return creative; }, async createCreative(data: CreativeCreateRequest): Promise { await delay(500); - // Validate {link} placeholder - if (!data.text.includes("{link}")) { - throw mockError( - "VALIDATION_ERROR", - "Creative text must contain {link} placeholder" - ); - } - const targetChannel = mockTargetChannels.find( (c) => c.id === data.target_channel_id ); @@ -340,13 +253,10 @@ export const mockApiHandlers = { name: data.name, text: data.text, target_channel_id: data.target_channel_id, - is_archived: false, + target_channel_title: targetChannel.title, created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - target_channel: targetChannel, - total_purchases: 0, - total_subscriptions: 0, - avg_cpf: null, + status: "active", + placements_count: 0, }; mockCreatives.push(newCreative); return newCreative; @@ -372,7 +282,6 @@ export const mockApiHandlers = { return { ...creative, ...data, - updated_at: new Date().toISOString(), }; }, @@ -383,10 +292,10 @@ export const mockApiHandlers = { throw mockError("NOT_FOUND", "Creative not found"); } - if (creative.total_purchases > 0) { + if (creative.placements_count > 0) { throw mockError( "CREATIVE_IN_USE", - "Cannot delete creative with active purchases" + "Cannot delete creative with active placements" ); } @@ -396,20 +305,16 @@ export const mockApiHandlers = { }, // -------------------------------------------------------------------------- - // Purchases + // Placements (Purchases) // -------------------------------------------------------------------------- - async getPurchases(params?: { + async getPlacements(params?: { target_channel_id?: string; external_channel_id?: string; creative_id?: string; - is_archived?: boolean; - date_from?: string; - date_to?: string; - sort?: string; - order?: "asc" | "desc"; - }): Promise> { + include_archived?: boolean; + }) { await delay(); - let filtered = [...mockPurchases]; + let filtered = [...mockPlacements]; if (params?.target_channel_id) { filtered = filtered.filter( @@ -427,62 +332,25 @@ export const mockApiHandlers = { filtered = filtered.filter((p) => p.creative_id === params.creative_id); } - if (params?.is_archived !== undefined) { - filtered = filtered.filter((p) => p.is_archived === params.is_archived); - } - - // Sorting - if (params?.sort) { - filtered.sort((a, b) => { - let aVal: any; - let bVal: any; - - switch (params.sort) { - case "date": - aVal = a.actual_date || a.scheduled_date || a.created_at; - bVal = b.actual_date || b.scheduled_date || b.created_at; - break; - case "cost": - aVal = a.cost || 0; - bVal = b.cost || 0; - break; - case "cpf": - aVal = a.cpf || 0; - bVal = b.cpf || 0; - break; - case "subscriptions": - aVal = a.subscriptions_count; - bVal = b.subscriptions_count; - break; - default: - return 0; - } - - if (params.order === "desc") { - return bVal > aVal ? 1 : -1; - } - return aVal > bVal ? 1 : -1; - }); + if (!params?.include_archived) { + filtered = filtered.filter((p) => p.status !== "archived"); } return { - data: filtered, - total: filtered.length, + placements: filtered, }; }, - async getPurchase(id: string) { + async getPlacement(id: string): Promise { await delay(); - const detail = getMockPurchaseDetail(id); - if (!detail) { - throw mockError("NOT_FOUND", "Purchase not found"); + const placement = mockPlacements.find((p) => p.id === id); + if (!placement) { + throw mockError("NOT_FOUND", "Placement not found"); } - return detail; + return placement; }, - async createPurchase( - data: PurchaseCreateRequest - ): Promise { + async createPlacement(data: PlacementCreateRequest): Promise { await delay(800); const targetChannel = mockTargetChannels.find( @@ -502,85 +370,111 @@ export const mockApiHandlers = { .toString(36) .substring(2, 15)}`; - const newPurchase: Purchase = { + const newPlacement: Placement = { id: generateId(), target_channel_id: data.target_channel_id, + target_channel_title: targetChannel.title, external_channel_id: data.external_channel_id, + external_channel_title: externalChannel.title, creative_id: data.creative_id, - scheduled_date: data.scheduled_date || null, - actual_date: data.actual_date || null, + creative_name: creative.name, + placement_date: data.placement_date, cost: data.cost || null, comment: data.comment || null, - post_link: data.post_link || null, + ad_post_url: data.ad_post_url || null, + invite_link_type: data.invite_link_type || "public", invite_link: inviteLink, - invite_link_type: data.invite_link_type, - is_archived: false, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - target_channel: targetChannel, - external_channel: externalChannel, - creative: creative, + status: "active", subscriptions_count: 0, views_count: null, - cpf: null, - cpm: null, - conversion_rate: null, + views_availability: "unknown", + last_views_fetch_at: null, + created_at: new Date().toISOString(), }; - mockPurchases.push(newPurchase); - - // Format message with invite link - const formattedMessage = creative.text.replace("{link}", inviteLink); - - return { - ...newPurchase, - formatted_message: formattedMessage, - }; + mockPlacements.push(newPlacement); + return newPlacement; }, - async updatePurchase( + async updatePlacement( id: string, - data: PurchaseUpdateRequest - ): Promise { + data: PlacementUpdateRequest + ): Promise { await delay(); - const purchase = mockPurchases.find((p) => p.id === id); - if (!purchase) { - throw mockError("NOT_FOUND", "Purchase not found"); + const placement = mockPlacements.find((p) => p.id === id); + if (!placement) { + throw mockError("NOT_FOUND", "Placement not found"); } return { - ...purchase, + ...placement, ...data, - updated_at: new Date().toISOString(), }; }, - async deletePurchase(id: string): Promise { + async deletePlacement(id: string): Promise { await delay(); - const index = mockPurchases.findIndex((p) => p.id === id); + const index = mockPlacements.findIndex((p) => p.id === id); if (index === -1) { - throw mockError("NOT_FOUND", "Purchase not found"); + throw mockError("NOT_FOUND", "Placement not found"); } - mockPurchases.splice(index, 1); + mockPlacements.splice(index, 1); return { success: true }; }, - async refreshPurchaseViews( - id: string - ): Promise { + async fetchPlacementViews(id: string): Promise { await delay(1000); - const purchase = mockPurchases.find((p) => p.id === id); - if (!purchase) { - throw mockError("NOT_FOUND", "Purchase not found"); + const placement = mockPlacements.find((p) => p.id === id); + if (!placement) { + throw mockError("NOT_FOUND", "Placement not found"); } // Mock: add some random views const newViews = - (purchase.views_count || 0) + Math.floor(Math.random() * 500); + (placement.views_count || 0) + Math.floor(Math.random() * 500); return { - views: newViews, + placement_id: id, + views_count: newViews, + views_availability: "available", fetched_at: new Date().toISOString(), + error_message: null, + }; + }, + + async getPlacementViewsHistory( + id: string, + params?: { from_date?: string; to_date?: string } + ): Promise { + await delay(); + const placement = mockPlacements.find((p) => p.id === id); + if (!placement) { + throw mockError("NOT_FOUND", "Placement not found"); + } + + const filteredHistory = mockViewsHistory.filter( + (v) => v.purchase_id === id + ); + return { + histories: filteredHistory, + }; + }, + + async setPlacementViewsManually( + id: string, + views_count: number + ): Promise { + await delay(); + const placement = mockPlacements.find((p) => p.id === id); + if (!placement) { + throw mockError("NOT_FOUND", "Placement not found"); + } + + return { + ...placement, + views_count, + views_availability: "manual", + last_views_fetch_at: new Date().toISOString(), }; }, diff --git a/lib/mocks/index.ts b/lib/mocks/index.ts index c1fbe70..e4c5366 100644 --- a/lib/mocks/index.ts +++ b/lib/mocks/index.ts @@ -6,5 +6,5 @@ export * from "./data/users"; export * from "./data/channels"; export * from "./data/external-channels"; export * from "./data/creatives"; -export * from "./data/purchases"; +export * from "./data/placements"; export * from "./data/analytics"; diff --git a/lib/types/api.ts b/lib/types/api.ts index 1ee8b4a..1c874ad 100644 --- a/lib/types/api.ts +++ b/lib/types/api.ts @@ -7,12 +7,9 @@ // ---------------------------------------------------------------------------- export interface User { - id: string; + id: string; // UUID telegram_id: number; username: string | null; - first_name: string; - last_name: string | null; - created_at: string; // ISO 8601 } export interface AuthInitResponse { @@ -42,189 +39,172 @@ export interface AuthRefreshResponse { // Target Channel // ---------------------------------------------------------------------------- +// Основная структура как возвращает API export interface TargetChannel { - id: string; + id: string; // UUID telegram_id: number; title: string; - username: string | null; // @channel_name - description: string | null; + username: string | null; is_active: boolean; - created_at: string; - updated_at: string; - // Stats - total_purchases: number; - total_subscriptions: number; - avg_cpf: number | null; // Average Cost Per Follower } +// Расширенная версия с дополнительными полями (пока не реализовано на бекенде) export interface TargetChannelDetail extends TargetChannel { - recent_purchases: Purchase[]; // last 5 - stats_by_period: { - period: "day" | "week" | "month"; - subscriptions: number; - cost: number; - cpf: number; - }[]; + // В будущем можно добавить статистику } export interface TargetChannelUpdateRequest { - is_active: boolean; + is_active?: boolean; +} + +export interface TargetChannelsListResponse { + target_channels: TargetChannel[]; } // ---------------------------------------------------------------------------- // External Channel // ---------------------------------------------------------------------------- +// Основная структура как возвращает API export interface ExternalChannel { - id: string; - telegram_id: number | null; + id: string; // UUID + telegram_id: number; title: string; username: string | null; - link: string; // t.me link or custom - subscribers_count: number | null; description: string | null; - created_at: string; - updated_at: string; - // Relations - target_channels: string[]; // IDs of target channels - // Stats - total_purchases: number; - avg_cpf: number | null; - avg_cpm: number | null; -} - -export interface ExternalChannelDetail extends ExternalChannel { - purchases: Purchase[]; + subscribers_count: number | null; } export interface ExternalChannelCreateRequest { + telegram_id: number; title: string; - username?: string; - link: string; - subscribers_count?: number; - description?: string; - target_channel_ids: string[]; // Привязка к целевым каналам + username?: string | null; + description?: string | null; + subscribers_count?: number | null; + target_channel_ids: string[]; // UUID[] } export interface ExternalChannelUpdateRequest { title?: string; - username?: string; - link?: string; - subscribers_count?: number; - description?: string; - target_channel_ids?: string[]; + username?: string | null; + description?: string | null; + subscribers_count?: number | null; } -export interface ExternalChannelImportResponse { - imported: number; - skipped: number; - errors: string[]; - channels: ExternalChannel[]; +export interface ExternalChannelLinksUpdateRequest { + add_target_channel_ids?: string[]; + remove_target_channel_ids?: string[]; +} + +export interface ExternalChannelsListResponse { + external_channels: ExternalChannel[]; } // ---------------------------------------------------------------------------- // Creative // ---------------------------------------------------------------------------- +export type CreativeStatus = "active" | "archived"; + export interface Creative { id: string; name: string; - text: string; // Template with {link} placeholder + text: string; target_channel_id: string; - is_archived: boolean; + target_channel_title: string; created_at: string; - updated_at: string; - // Relations - target_channel: TargetChannel; - // Stats - total_purchases: number; - total_subscriptions: number; - avg_cpf: number | null; + status: CreativeStatus; + placements_count: number; } -export interface CreativeDetail extends Creative { - purchases: Purchase[]; +export interface CreativesListResponse { + creatives: Creative[]; } export interface CreativeCreateRequest { name: string; - text: string; // Must contain {link} placeholder + text: string; target_channel_id: string; } export interface CreativeUpdateRequest { name?: string; text?: string; - is_archived?: boolean; + status?: CreativeStatus; } // ---------------------------------------------------------------------------- -// Purchase +// Placement (Purchase) // ---------------------------------------------------------------------------- -export type InviteLinkType = "public" | "private"; +export type InviteLinkType = "public" | "approval"; +export type PlacementStatus = "active" | "archived"; +export type ViewsAvailability = + | "unknown" + | "available" + | "unavailable" + | "manual"; -export interface Purchase { +export interface Placement { id: string; target_channel_id: string; + target_channel_title: string; external_channel_id: string; + external_channel_title: string; creative_id: string; - // Purchase details - scheduled_date: string | null; // ISO 8601 - actual_date: string | null; - cost: number | null; // in rubles + creative_name: string; + placement_date: string; + cost: number | null; comment: string | null; - post_link: string | null; // Link to ad post in external channel - invite_link: string; // Generated invite link - invite_link_type: InviteLinkType; // with bot approval or not - is_archived: boolean; - created_at: string; - updated_at: string; - // Relations - target_channel: TargetChannel; - external_channel: ExternalChannel; - creative: Creative; - // Stats + ad_post_url: string | null; + invite_link_type: InviteLinkType; + invite_link: string; + status: PlacementStatus; subscriptions_count: number; views_count: number | null; - cpf: number | null; // Cost Per Follower - cpm: number | null; // Cost Per Mile (1000 views) - conversion_rate: number | null; // subscriptions / views * 100 + views_availability: ViewsAvailability; + last_views_fetch_at: string | null; + created_at: string; } -export interface PurchaseDetail extends Purchase { - subscriptions: Subscription[]; - views_history: ViewsSnapshot[]; +export interface PlacementsListResponse { + placements: Placement[]; } -export interface PurchaseCreateRequest { +export interface PlacementCreateRequest { target_channel_id: string; external_channel_id: string; creative_id: string; - scheduled_date?: string; - actual_date?: string; - cost?: number; - comment?: string; - post_link?: string; - invite_link_type: InviteLinkType; + placement_date: string; + cost?: number | null; + comment?: string | null; + ad_post_url?: string | null; + invite_link_type?: InviteLinkType; } -export interface PurchaseCreateResponse extends Purchase { - formatted_message: string; // Creative text with invite_link inserted +export interface PlacementUpdateRequest { + placement_date?: string; + cost?: number | null; + comment?: string | null; + ad_post_url?: string | null; + status?: PlacementStatus; } -export interface PurchaseUpdateRequest { - scheduled_date?: string; - actual_date?: string; - cost?: number; - comment?: string; - post_link?: string; - is_archived?: boolean; -} +// Legacy aliases for backwards compatibility +export type Purchase = Placement; +export type PurchaseCreateRequest = PlacementCreateRequest; +export type PurchaseUpdateRequest = PlacementUpdateRequest; -export interface PurchaseRefreshViewsResponse { - views: number; +export interface ViewsFetchResponse { + placement_id: string; + views_count: number | null; + views_availability: ViewsAvailability; fetched_at: string; + error_message: string | null; +} + +export interface ViewsHistoryResponse { + histories: ViewsSnapshot[]; } // ---------------------------------------------------------------------------- @@ -372,7 +352,7 @@ export interface CreativeQueryParams { is_archived?: boolean; } -export interface PurchaseQueryParams { +export interface PlacementQueryParams { target_channel_id?: string; external_channel_id?: string; creative_id?: string; diff --git a/lib/utils/constants.ts b/lib/utils/constants.ts index 78ffbf8..9c96d3c 100644 --- a/lib/utils/constants.ts +++ b/lib/utils/constants.ts @@ -3,9 +3,11 @@ // ============================================================================ export const API_URL = - process.env.NEXT_PUBLIC_API_URL || "https://api.tgex.app/v1"; -export const USE_MOCKS = process.env.NEXT_PUBLIC_USE_MOCKS === "true"; -export const BOT_USERNAME = process.env.NEXT_PUBLIC_BOT_USERNAME || "tgex_bot"; + process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1"; +export const USE_MOCKS = + process.env.NEXT_PUBLIC_USE_MOCKS?.toLowerCase() === "true" || false; +export const BOT_USERNAME = + process.env.NEXT_PUBLIC_BOT_USERNAME || "your_bot_username"; // Local Storage Keys export const STORAGE_KEYS = { @@ -76,7 +78,7 @@ export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; export const ROUTES = { HOME: "/", LOGIN: "/login", - AUTH_COMPLETE: "/auth-complete", + AUTH_COMPLETE: "/auth/complete", CHANNELS: "/channels", CHANNEL_DETAIL: (id: string) => `/channels/${id}`, diff --git a/lib/utils/file-parser.ts b/lib/utils/file-parser.ts new file mode 100644 index 0000000..88dac20 --- /dev/null +++ b/lib/utils/file-parser.ts @@ -0,0 +1,191 @@ +// ============================================================================ +// File Parser Utilities +// ============================================================================ + +import * as XLSX from "xlsx"; +import Papa from "papaparse"; + +export interface ParsedChannel { + telegram_id?: number; + title: string; + username?: string; + description?: string; + subscribers_count?: number; + row: number; // Номер строки для отчетов об ошибках +} + +export interface ParseResult { + channels: ParsedChannel[]; + errors: string[]; +} + +/** + * Парсит Excel файл (xlsx) + */ +export const parseExcelFile = async (file: File): Promise => { + const errors: string[] = []; + const channels: ParsedChannel[] = []; + + try { + const arrayBuffer = await file.arrayBuffer(); + const workbook = XLSX.read(arrayBuffer, { type: "array" }); + const sheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[sheetName]; + const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }); + + // Пропускаем заголовок (первая строка) + const rows = jsonData.slice(1) as any[][]; + + rows.forEach((row, index) => { + const rowNumber = index + 2; // +2 потому что пропустили заголовок и индекс с 0 + + // Пропускаем пустые строки + if (!row || row.length === 0 || !row[0]) { + return; + } + + try { + const channel: ParsedChannel = { + row: rowNumber, + title: String(row[0] || "").trim(), + username: row[1] ? String(row[1]).trim().replace("@", "") : undefined, + telegram_id: row[2] ? parseInt(String(row[2])) : undefined, + subscribers_count: row[3] ? parseInt(String(row[3])) : undefined, + description: row[4] ? String(row[4]).trim() : undefined, + }; + + if (!channel.title) { + errors.push(`Строка ${rowNumber}: отсутствует название канала`); + return; + } + + channels.push(channel); + } catch (err) { + errors.push( + `Строка ${rowNumber}: ошибка парсинга - ${ + err instanceof Error ? err.message : "неизвестная ошибка" + }` + ); + } + }); + } catch (err) { + errors.push( + `Ошибка чтения файла: ${ + err instanceof Error ? err.message : "неизвестная ошибка" + }` + ); + } + + return { channels, errors }; +}; + +/** + * Парсит CSV файл + */ +export const parseCsvFile = async (file: File): Promise => { + return new Promise((resolve) => { + const errors: string[] = []; + const channels: ParsedChannel[] = []; + + Papa.parse(file, { + header: false, + skipEmptyLines: true, + complete: (results) => { + const rows = results.data as string[][]; + + // Пропускаем заголовок + rows.slice(1).forEach((row, index) => { + const rowNumber = index + 2; + + if (!row || row.length === 0 || !row[0]) { + return; + } + + try { + const channel: ParsedChannel = { + row: rowNumber, + title: (row[0] || "").trim(), + username: row[1] ? row[1].trim().replace("@", "") : undefined, + telegram_id: row[2] ? parseInt(row[2]) : undefined, + subscribers_count: row[3] ? parseInt(row[3]) : undefined, + description: row[4] ? row[4].trim() : undefined, + }; + + if (!channel.title) { + errors.push(`Строка ${rowNumber}: отсутствует название канала`); + return; + } + + channels.push(channel); + } catch (err) { + errors.push( + `Строка ${rowNumber}: ошибка парсинга - ${ + err instanceof Error ? err.message : "неизвестная ошибка" + }` + ); + } + }); + + resolve({ channels, errors }); + }, + error: (err) => { + errors.push(`Ошибка парсинга CSV: ${err.message}`); + resolve({ channels: [], errors }); + }, + }); + }); +}; + +/** + * Универсальная функция парсинга файла + */ +export const parseChannelsFile = async (file: File): Promise => { + const extension = file.name.split(".").pop()?.toLowerCase(); + + if (extension === "xlsx" || extension === "xls") { + return parseExcelFile(file); + } else if (extension === "csv") { + return parseCsvFile(file); + } else { + return { + channels: [], + errors: [ + "Неподдерживаемый формат файла. Поддерживаются только .xlsx, .xls и .csv", + ], + }; + } +}; + +/** + * Скачать шаблон для импорта + */ +export const downloadTemplate = (format: "xlsx" | "csv") => { + const headers = [ + "Название канала*", + "Username (без @)", + "Telegram ID", + "Подписчиков", + "Описание", + ]; + const example = [ + "Пример канала", + "example_channel", + "-1001234567890", + "10000", + "Описание канала", + ]; + + if (format === "xlsx") { + const ws = XLSX.utils.aoa_to_sheet([headers, example]); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, "Channels"); + XLSX.writeFile(wb, "channels_template.xlsx"); + } else { + const csv = Papa.unparse([headers, example]); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.download = "channels_template.csv"; + link.click(); + } +}; diff --git a/package.json b/package.json index 05d0794..ec75daa 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", @@ -37,6 +38,7 @@ "lucide-react": "^0.553.0", "next": "16.0.1", "next-themes": "^0.4.6", + "papaparse": "^5.5.3", "react": "19.2.0", "react-day-picker": "^9.11.1", "react-dom": "19.2.0", @@ -45,11 +47,13 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "vaul": "^1.1.2", + "xlsx": "^0.18.5", "zod": "^4.1.12" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/papaparse": "^5.5.0", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 228c71f..0323b5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@radix-ui/react-popover': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-progress': + specifier: ^1.1.8 + version: 1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@radix-ui/react-radio-group': specifier: ^1.3.8 version: 1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -92,6 +95,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + papaparse: + specifier: ^5.5.3 + version: 5.5.3 react: specifier: 19.2.0 version: 19.2.0 @@ -116,6 +122,9 @@ importers: vaul: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + xlsx: + specifier: ^0.18.5 + version: 0.18.5 zod: specifier: ^4.1.12 version: 4.1.12 @@ -126,6 +135,9 @@ importers: '@types/node': specifier: ^20 version: 20.19.24 + '@types/papaparse': + specifier: ^5.5.0 + version: 5.5.0 '@types/react': specifier: ^19 version: 19.2.2 @@ -846,6 +858,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-progress@1.1.8': + resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-radio-group@1.3.8': resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} peerDependencies: @@ -1223,6 +1248,9 @@ packages: '@types/node@20.19.24': resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==} + '@types/papaparse@5.5.0': + resolution: {integrity: sha512-GVs5iMQmUr54BAZYYkByv8zPofFxmyxUpISPb2oh8sayR3+1zbxasrOvoKiHJ/nnoq/uULuPsu1Lze1EkagVFg==} + '@types/react-dom@19.2.2': resolution: {integrity: sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==} peerDependencies: @@ -1395,6 +1423,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -1505,6 +1537,10 @@ packages: caniuse-lite@1.0.30001754: resolution: {integrity: sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==} + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1519,6 +1555,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1532,6 +1572,11 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1878,6 +1923,10 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -2378,6 +2427,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + papaparse@5.5.3: + resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2614,6 +2666,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -2815,10 +2871,23 @@ packages: engines: {node: '>= 8'} hasBin: true + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3519,6 +3588,16 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-progress@1.1.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/react-context': 1.1.3(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -3871,6 +3950,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/papaparse@5.5.0': + dependencies: + '@types/node': 20.19.24 + '@types/react-dom@19.2.2(@types/react@19.2.2)': dependencies: '@types/react': 19.2.2 @@ -4037,6 +4120,8 @@ snapshots: acorn@8.15.0: {} + adler-32@1.3.1: {} + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -4181,6 +4266,11 @@ snapshots: caniuse-lite@1.0.30001754: {} + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -4194,6 +4284,8 @@ snapshots: clsx@2.1.1: {} + codepage@1.15.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4204,6 +4296,8 @@ snapshots: convert-source-map@2.0.0: {} + crc-32@1.2.2: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4692,6 +4786,8 @@ snapshots: dependencies: is-callable: 1.2.7 + frac@1.1.2: {} + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -5173,6 +5269,8 @@ snapshots: dependencies: p-limit: 3.1.0 + papaparse@5.5.3: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5460,6 +5558,10 @@ snapshots: source-map-js@1.2.1: {} + ssf@0.11.2: + dependencies: + frac: 1.1.2 + stable-hash@0.0.5: {} stop-iteration-iterator@1.1.0: @@ -5749,8 +5851,22 @@ snapshots: dependencies: isexe: 2.0.0 + wmf@1.0.2: {} + word-wrap@1.2.5: {} + word@0.3.0: {} + + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + yallist@3.1.1: {} yocto-queue@0.1.0: {}