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.is_active ? "Отключить" : "Включить"}
-
-
- Удалить
-
-
-
-
- {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}
+
-
-
-
- Подробнее
-
-
- handleToggleActive(channel.id, channel.is_active)
+ handleDisconnect(channel.id, channel.title)
}
+ className="flex-1"
>
- {channel.is_active ? "Отключить" : "Включить"}
+ Отключить канал
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}
- {creative.is_archived ? "Разархивировать" : "Архивировать"}
+ {creative.status === "archived"
+ ? "Разархивировать"
+ : "Архивировать"}
@@ -190,10 +197,10 @@ export default function CreativeDetailPage({ params }: PageProps) {
- {formatNumber(creative.total_purchases)}
+ {formatNumber(creative.placements_count)}
- С этим креативом
+ Всего размещений
@@ -201,29 +208,16 @@ export default function CreativeDetailPage({ params }: PageProps) {
- Подписчиков привлечено
+ Целевой канал
-
- {formatNumber(creative.total_subscriptions)}
-
- Всего
-
-
-
-
-
- Средний CPF
-
-
-
-
- ₽{formatMetric(creative.avg_cpf)}
+
+ {creative.target_channel_title}
- По всем закупам
+ Канал для рекламы
@@ -261,58 +255,29 @@ export default function CreativeDetailPage({ params }: PageProps) {
- Закупы с этим креативом
- История использования креатива
+ Информация о размещениях
+ Статистика использования креатива
- {creative.purchases.length === 0 ? (
-
- Закупов с этим креативом пока нет
-
- ) : (
-
-
-
- Внешний канал
- Дата
- Стоимость
- Подписчики
- CPF
-
-
-
-
- {creative.purchases.map((purchase) => (
-
-
- {purchase.external_channel.title}
-
-
- {formatDate(
- purchase.actual_date || purchase.scheduled_date
- )}
-
-
- {formatCurrency(purchase.cost)}
-
-
- {formatNumber(purchase.subscriptions_count)}
-
-
- ₽{formatMetric(purchase.cpf)}
-
-
-
-
- Открыть
-
-
-
-
- ))}
-
-
- )}
+
+
+
Всего размещений:
+
+ {creative.placements_count}
+
+
+
+
Создан:
+
{formatDate(creative.created_at)}
+
+
+
+
+ Посмотреть все размещения
+
+
+
+
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.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 с данными каналов
@@ -143,195 +220,307 @@ export default function ImportExternalChannelsPage() {
+ {/* Выбор целевого канала */}
- Формат файла
+ Целевой канал
- Требования к структуре Excel файла
+ Выберите целевой канал для привязки импортируемых каналов
-
-
Файл должен содержать следующие колонки:
-
-
- Название - название канала
-
-
- Username - username канала (с @ или без)
-
-
- Ссылка - ссылка на канал (t.me/...)
-
-
- Подписчики - количество подписчиков (число)
-
-
- Описание - краткое описание (опционально)
-
-
-
-
-
- Скачать шаблон
-
-
+
+
+
+
+
+ {targetChannels.map((channel) => (
+
+ {channel.title}
+ {channel.username && ` (@${channel.username})`}
+
+ ))}
+
+
+
+
+
+ {/* Шаблоны */}
+
+
+ Скачать шаблон
+
+ Используйте шаблон для правильного заполнения данных
+
+
+
+ downloadTemplate("xlsx")}
+ disabled={isUploading}
+ >
+
+ Шаблон Excel
+
+ downloadTemplate("csv")}
+ disabled={isUploading}
+ >
+
+ Шаблон CSV
+
+
+
+
+ {/* Загрузка файла */}
+
+
+ Загрузка файла
+
+ Перетащите файл или нажмите для выбора
+
+
+
+
+
+
+
+ {file ? (
+
+
{file.name}
+
+ {(file.size / 1024).toFixed(2)} KB
+
+ {!isUploading && (
+
{
+ e.preventDefault();
+ handleReset();
+ }}
+ >
+
+ Удалить
+
+ )}
+
+ ) : (
+ <>
+
+ Перетащите файл сюда или нажмите для выбора
+
+
+ Поддерживаются форматы: .xlsx, .xls, .csv
+
+ >
+ )}
+
- {!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 ? (
- <>
-
- Импорт...
- >
- ) : (
- <>
-
- Импортировать
- >
- )}
-
-
- )}
+
+
+
+ Импортировать {parsedChannels.length} каналов
+
+
+ Отмена
+
+
- ) : (
+ )}
+
+ {/* Прогресс импорта */}
+ {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}
+
+
+ )}
+
+
+ router.push("/external-channels")}
+ className="flex-1"
+ >
+ Перейти к списку каналов
+
Импортировать еще
- Перейти к каналам
)}
+
+ {/* Инструкция */}
+
+
+ Формат файла
+ Требования к структуре файла
+
+
+
+
+ Колонки (в порядке):
+
+
+
+ Название канала* - обязательное поле
+
+
+ Username - без символа @ (опционально)
+
+
+ Telegram ID - числовой ID канала
+ (опционально)
+
+
+ Подписчиков - количество подписчиков
+ (опционально)
+
+
+ Описание - описание канала (опционально)
+
+
+
+ * Первая строка файла должна содержать заголовки и будет
+ пропущена при импорте
+
+
+
+
>
);
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
-
-
-
- Подробнее
-
-
- handleDelete(channel.id, channel.title)}
>
-
+
+ Удалить
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.is_archived ? "Разархивировать" : "Архивировать"}
+ {purchase.status === "archived"
+ ? "Разархивировать"
+ : "Архивировать"}
@@ -272,27 +260,45 @@ export default function PurchaseDetailPage({ params }: PageProps) {
- CPF
+
+ Статус просмотров
+
-
- {purchase.cpf ? `₽${formatMetric(purchase.cpf)}` : "—"}
+
+ {purchase.views_availability === "available"
+ ? "Доступны"
+ : purchase.views_availability === "manual"
+ ? "Вручную"
+ : purchase.views_availability === "unavailable"
+ ? "Недоступны"
+ : "Неизвестно"}
+
+ {purchase.last_views_fetch_at
+ ? `Обновлено: ${formatDate(purchase.last_views_fetch_at)}`
+ : "Еще не обновлялись"}
+
- Конверсия
+ Тип ссылки
-
- {purchase.conversion_rate
- ? formatPercent(purchase.conversion_rate)
- : "—"}
+
+ {purchase.invite_link_type === "approval"
+ ? "С одобрением"
+ : "Публичная"}
+
+ {purchase.invite_link_type === "approval"
+ ? "Отслеживание подписчиков"
+ : "Без отслеживания"}
+
@@ -318,18 +324,18 @@ export default function PurchaseDetailPage({ params }: PageProps) {
)}
- {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: {}