improvement for the backend api
This commit is contained in:
@@ -1,739 +0,0 @@
|
||||
# API Документация
|
||||
|
||||
## Базовый URL
|
||||
|
||||
```
|
||||
https://api.tgex.app/v1
|
||||
```
|
||||
|
||||
## Аутентификация
|
||||
|
||||
Все запросы (кроме `/auth/*`) требуют JWT токен в заголовке:
|
||||
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
704
FRONTEND_API.md
Normal file
704
FRONTEND_API.md
Normal file
@@ -0,0 +1,704 @@
|
||||
# API Документация для Frontend
|
||||
|
||||
## Базовая информация
|
||||
|
||||
- **Base URL**: `/api/v1` (для всех эндпоинтов)
|
||||
- **Формат данных**: JSON
|
||||
- **Аутентификация**: JWT Bearer Token (в заголовке `Authorization: Bearer <token>`)
|
||||
|
||||
## Авторизация
|
||||
|
||||
### Процесс авторизации через Telegram
|
||||
|
||||
1. Пользователь запускает Telegram бота командой `/start` с параметром `login`
|
||||
2. Бот создает одноразовый токен (живет 10 минут) и отправляет ссылку на веб-приложение
|
||||
3. Фронтенд получает `token` из URL параметра (`/auth/complete?token=...`)
|
||||
4. Фронтенд отправляет запрос на `GET /api/v1/auth/complete?token=<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 <access_token>`
|
||||
|
||||
**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 <access_token>`
|
||||
|
||||
**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 <access_token>`
|
||||
|
||||
**Path параметры:**
|
||||
|
||||
- `channel_id` (UUID) - ID канала
|
||||
|
||||
**Response 200:** пустой ответ
|
||||
|
||||
---
|
||||
|
||||
## Внешние каналы (External Channels)
|
||||
|
||||
Каналы, в которых размещается реклама.
|
||||
|
||||
### GET `/api/v1/external_channels/target/{target_channel_id}`
|
||||
|
||||
Получить все внешние каналы, связанные с конкретным целевым каналом.
|
||||
|
||||
**Headers:**
|
||||
|
||||
- `Authorization: Bearer <access_token>`
|
||||
|
||||
**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 <access_token>`
|
||||
|
||||
**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 <access_token>`
|
||||
|
||||
**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 <access_token>`
|
||||
|
||||
**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 <access_token>`
|
||||
|
||||
**Path параметры:**
|
||||
|
||||
- `channel_id` (UUID) - ID канала
|
||||
|
||||
**Response 200:** пустой ответ
|
||||
|
||||
---
|
||||
|
||||
## Креативы (Creatives)
|
||||
|
||||
Рекламные сообщения для размещения.
|
||||
|
||||
### GET `/api/v1/creatives`
|
||||
|
||||
Получить список креативов.
|
||||
|
||||
**Headers:**
|
||||
|
||||
- `Authorization: Bearer <access_token>`
|
||||
|
||||
**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 <access_token>`
|
||||
|
||||
**Path параметры:**
|
||||
|
||||
- `creative_id` (UUID) - ID креатива
|
||||
|
||||
**Response 200:** объект `CreativeOutput`
|
||||
|
||||
### POST `/api/v1/creatives`
|
||||
|
||||
Создать новый креатив.
|
||||
|
||||
**Headers:**
|
||||
|
||||
- `Authorization: Bearer <access_token>`
|
||||
|
||||
**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 <access_token>`
|
||||
|
||||
**Path параметры:**
|
||||
|
||||
- `creative_id` (UUID) - ID креатива
|
||||
|
||||
**Request body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Новое название",
|
||||
"text": "Новый текст",
|
||||
"status": "archived"
|
||||
}
|
||||
```
|
||||
|
||||
**Все поля опциональны.**
|
||||
|
||||
**Response 200:** объект `CreativeOutput`
|
||||
|
||||
### DELETE `/api/v1/creatives/{creative_id}`
|
||||
|
||||
Удалить креатив.
|
||||
|
||||
**Headers:**
|
||||
|
||||
- `Authorization: Bearer <access_token>`
|
||||
|
||||
**Path параметры:**
|
||||
|
||||
- `creative_id` (UUID) - ID креатива
|
||||
|
||||
**Response 200:** пустой ответ
|
||||
|
||||
---
|
||||
|
||||
## Размещения (Placements)
|
||||
|
||||
Размещения рекламы во внешних каналах.
|
||||
|
||||
### GET `/api/v1/placements`
|
||||
|
||||
Получить список размещений.
|
||||
|
||||
**Headers:**
|
||||
|
||||
- `Authorization: Bearer <access_token>`
|
||||
|
||||
**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 <access_token>`
|
||||
|
||||
**Path параметры:**
|
||||
|
||||
- `placement_id` (UUID) - ID размещения
|
||||
|
||||
**Response 200:** объект `PlacementOutput`
|
||||
|
||||
### POST `/api/v1/placements`
|
||||
|
||||
Создать новое размещение.
|
||||
|
||||
**Headers:**
|
||||
|
||||
- `Authorization: Bearer <access_token>`
|
||||
|
||||
**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 <access_token>`
|
||||
|
||||
**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 <access_token>`
|
||||
|
||||
**Path параметры:**
|
||||
|
||||
- `placement_id` (UUID) - ID размещения
|
||||
|
||||
**Response 200:** пустой ответ
|
||||
|
||||
---
|
||||
|
||||
## Просмотры (Views)
|
||||
|
||||
Работа с просмотрами рекламных постов.
|
||||
|
||||
### GET `/api/v1/placements/{placement_id}/views/history`
|
||||
|
||||
Получить историю просмотров размещения.
|
||||
|
||||
**Headers:**
|
||||
|
||||
- `Authorization: Bearer <access_token>`
|
||||
|
||||
**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 <access_token>`
|
||||
|
||||
**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 <access_token>`
|
||||
|
||||
**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 <access_token>
|
||||
```
|
||||
|
||||
При отсутствии или невалидном токене вернется `401 Unauthorized`.
|
||||
|
||||
---
|
||||
|
||||
## Примечания
|
||||
|
||||
1. **Soft delete**: При удалении сущностей (креативы, размещения, внешние каналы) они помечаются как удаленные (`deleted_at`), но не удаляются физически из БД.
|
||||
|
||||
2. **Отслеживание подписчиков**: Работает только для размещений с `invite_link_type: "approval"`. Бот автоматически одобряет запросы на вступление и увеличивает счетчик подписок.
|
||||
|
||||
3. **Автоматическое обновление просмотров**: Фоновый worker периодически обновляет просмотры для активных размещений. Можно также запросить обновление вручную через `/api/v1/placements/{id}/views/fetch`.
|
||||
|
||||
4. **Целевые каналы подключаются только через Telegram бота**: Нельзя создать целевой канал через API. Нужно добавить бота в канал как администратора, и он автоматически зарегистрирует канал в системе.
|
||||
@@ -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
|
||||
@@ -47,7 +47,7 @@ function AuthCompleteContent() {
|
||||
}, 1500);
|
||||
} catch (err: any) {
|
||||
setStatus("error");
|
||||
setError(err?.error?.message || "Ошибка авторизации");
|
||||
setError(err?.detail || err?.error?.message || "Ошибка авторизации");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 || "Ошибка загрузки аналитики");
|
||||
|
||||
@@ -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<TargetChannelDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
// Redirect back to channels list
|
||||
// Detail page functionality not yet implemented in backend API
|
||||
router.push("/channels");
|
||||
} catch (err: any) {
|
||||
alert(err?.error?.message || "Ошибка удаления канала");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Целевые каналы", href: "/channels" },
|
||||
{ label: "Загрузка..." },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !channel) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Целевые каналы", href: "/channels" },
|
||||
{ label: "Ошибка" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error || "Канал не найден"}</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/channels">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Вернуться к каналам
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: "/" },
|
||||
{ label: "Целевые каналы", href: "/channels" },
|
||||
{ label: channel.title },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Target className="h-6 w-6" />
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{channel.title}
|
||||
</h1>
|
||||
<Badge variant={channel.is_active ? "default" : "secondary"}>
|
||||
{channel.is_active ? "Активен" : "Отключен"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
{channel.username ? (
|
||||
<a
|
||||
href={`https://t.me/${channel.username.replace("@", "")}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline flex items-center gap-1"
|
||||
>
|
||||
{formatUsername(channel.username)}
|
||||
<ExternalLinkIcon className="h-3 w-3" />
|
||||
</a>
|
||||
) : (
|
||||
<span>Приватный канал</span>
|
||||
)}
|
||||
<span>•</span>
|
||||
<span>Добавлен {formatDate(channel.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleToggleActive}>
|
||||
{channel.is_active ? "Отключить" : "Включить"}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{channel.description && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Описание</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">{channel.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Всего закупов
|
||||
</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(channel.total_purchases)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
За весь период
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Подписчиков привлечено
|
||||
</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(channel.total_subscriptions)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Всего</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Средний CPF</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
₽{formatMetric(channel.avg_cpf)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Стоимость подписчика
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="stats" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="stats">Статистика по периодам</TabsTrigger>
|
||||
<TabsTrigger value="purchases">Последние закупы</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="stats" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Статистика по периодам</CardTitle>
|
||||
<CardDescription>
|
||||
Динамика привлечения подписчиков
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Период</TableHead>
|
||||
<TableHead className="text-right">Подписчики</TableHead>
|
||||
<TableHead className="text-right">Затраты</TableHead>
|
||||
<TableHead className="text-right">CPF</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{channel.stats_by_period.map((stat, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className="font-medium capitalize">
|
||||
{stat.period}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(stat.subscriptions)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(stat.cost)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
₽{formatMetric(stat.cpf)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="purchases" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Последние закупы</CardTitle>
|
||||
<CardDescription>
|
||||
5 последних рекламных размещений
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{channel.recent_purchases.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
Закупов пока нет
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Внешний канал</TableHead>
|
||||
<TableHead>Дата</TableHead>
|
||||
<TableHead className="text-right">Подписчики</TableHead>
|
||||
<TableHead className="text-right">CPF</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{channel.recent_purchases.map((purchase) => (
|
||||
<TableRow key={purchase.id}>
|
||||
<TableCell className="font-medium">
|
||||
{purchase.external_channel.title}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDate(
|
||||
purchase.actual_date || purchase.scheduled_date
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(purchase.subscriptions_count)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
₽{formatMetric(purchase.cpf)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/purchases/${purchase.id}`}>
|
||||
Открыть
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}, [router, resolvedParams.id]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{channel.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{channel.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-lg border bg-muted/50 p-2">
|
||||
<div className="text-sm font-medium">
|
||||
{formatNumber(channel.total_purchases)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Закупов
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/50 p-2">
|
||||
<div className="text-sm font-medium">
|
||||
{formatNumber(channel.total_subscriptions)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Подписчиков
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/50 p-2">
|
||||
<div className="text-sm font-medium">
|
||||
₽{formatMetric(channel.avg_cpf)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
CPF
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Telegram ID:{" "}
|
||||
<code className="text-xs bg-muted px-1 rounded">
|
||||
{channel.telegram_id}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
<Link href={`/channels/${channel.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Подробнее
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleToggleActive(channel.id, channel.is_active)
|
||||
handleDisconnect(channel.id, channel.title)
|
||||
}
|
||||
className="flex-1"
|
||||
>
|
||||
{channel.is_active ? "Отключить" : "Включить"}
|
||||
Отключить канал
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -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<CreativeDetail | null>(null);
|
||||
const [creative, setCreative] = useState<Creative | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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) {
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{creative.name}
|
||||
</h1>
|
||||
{creative.is_archived && <Badge variant="secondary">Архив</Badge>}
|
||||
{creative.status === "archived" && (
|
||||
<Badge variant="secondary">Архив</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Целевой канал: {creative.target_channel.title}
|
||||
Целевой канал: {creative.target_channel_title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleArchive}>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
{creative.is_archived ? "Разархивировать" : "Архивировать"}
|
||||
{creative.status === "archived"
|
||||
? "Разархивировать"
|
||||
: "Архивировать"}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
@@ -190,10 +197,10 @@ export default function CreativeDetailPage({ params }: PageProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(creative.total_purchases)}
|
||||
{formatNumber(creative.placements_count)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
С этим креативом
|
||||
Всего размещений
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -201,29 +208,16 @@ export default function CreativeDetailPage({ params }: PageProps) {
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Подписчиков привлечено
|
||||
Целевой канал
|
||||
</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(creative.total_subscriptions)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Всего</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Средний CPF</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
₽{formatMetric(creative.avg_cpf)}
|
||||
<div className="text-lg font-medium">
|
||||
{creative.target_channel_title}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
По всем закупам
|
||||
Канал для рекламы
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -261,58 +255,29 @@ export default function CreativeDetailPage({ params }: PageProps) {
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Закупы с этим креативом</CardTitle>
|
||||
<CardDescription>История использования креатива</CardDescription>
|
||||
<CardTitle>Информация о размещениях</CardTitle>
|
||||
<CardDescription>Статистика использования креатива</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{creative.purchases.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
Закупов с этим креативом пока нет
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Всего размещений:</h4>
|
||||
<p className="text-2xl font-bold">
|
||||
{creative.placements_count}
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Внешний канал</TableHead>
|
||||
<TableHead>Дата</TableHead>
|
||||
<TableHead className="text-right">Стоимость</TableHead>
|
||||
<TableHead className="text-right">Подписчики</TableHead>
|
||||
<TableHead className="text-right">CPF</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{creative.purchases.map((purchase) => (
|
||||
<TableRow key={purchase.id}>
|
||||
<TableCell className="font-medium">
|
||||
{purchase.external_channel.title}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDate(
|
||||
purchase.actual_date || purchase.scheduled_date
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(purchase.cost)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(purchase.subscriptions_count)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
₽{formatMetric(purchase.cpf)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/purchases/${purchase.id}`}>
|
||||
Открыть
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Создан:</h4>
|
||||
<p className="text-sm">{formatDate(creative.created_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href={`/purchases?creative_id=${creative.id}`}>
|
||||
Посмотреть все размещения
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="active">
|
||||
Активные ({creatives.filter((c) => !c.is_archived).length})
|
||||
Активные ({creatives.filter((c) => c.status === "active").length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="archived">
|
||||
Архив ({creatives.filter((c) => c.is_archived).length})
|
||||
Архив ({creatives.filter((c) => c.status === "archived").length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="all">Все ({creatives.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -166,12 +175,12 @@ export default function CreativesPage() {
|
||||
<CardTitle className="truncate text-base">
|
||||
{creative.name}
|
||||
</CardTitle>
|
||||
{creative.is_archived && (
|
||||
{creative.status === "archived" && (
|
||||
<Badge variant="secondary">Архив</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="text-xs">
|
||||
{creative.target_channel.title}
|
||||
{creative.target_channel_title}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Folder className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
@@ -184,29 +193,21 @@ export default function CreativesPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="grid grid-cols-2 gap-2 text-center">
|
||||
<div className="rounded-lg border bg-muted/50 p-2">
|
||||
<div className="text-sm font-medium">
|
||||
{formatNumber(creative.total_purchases)}
|
||||
{formatNumber(creative.placements_count)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Закупов
|
||||
Размещений
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/50 p-2">
|
||||
<div className="text-sm font-medium">
|
||||
{formatNumber(creative.total_subscriptions)}
|
||||
<div className="text-xs font-medium">
|
||||
{formatDate(creative.created_at)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Подписчиков
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/50 p-2">
|
||||
<div className="text-sm font-medium">
|
||||
₽{formatMetric(creative.avg_cpf)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
CPF
|
||||
Создан
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -227,7 +228,7 @@ export default function CreativesPage() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleArchive(creative.id, creative.is_archived)
|
||||
handleArchive(creative.id, creative.status)
|
||||
}
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
|
||||
@@ -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<ExternalChannelDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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);
|
||||
}
|
||||
};
|
||||
|
||||
loadChannel();
|
||||
}, [resolvedParams.id]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!channel) return;
|
||||
if (!confirm(`Удалить канал "${channel.title}"?`)) return;
|
||||
|
||||
try {
|
||||
await externalChannelsApi.delete(channel.id);
|
||||
// Redirect back to external channels list
|
||||
// Detail page functionality not yet implemented in backend API
|
||||
router.push("/external-channels");
|
||||
} catch (err: any) {
|
||||
alert(err?.error?.message || "Ошибка удаления канала");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Внешние каналы", href: "/external-channels" },
|
||||
{ label: "Загрузка..." },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !channel) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Внешние каналы", href: "/external-channels" },
|
||||
{ label: "Ошибка" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error || "Канал не найден"}</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/external-channels">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Вернуться к каналам
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: "/" },
|
||||
{ label: "Внешние каналы", href: "/external-channels" },
|
||||
{ label: channel.title },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ExternalLinkIcon className="h-6 w-6" />
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{channel.title}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
{channel.username ? (
|
||||
<a
|
||||
href={channel.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline flex items-center gap-1"
|
||||
>
|
||||
{formatUsername(channel.username)}
|
||||
<ExternalLinkIcon className="h-3 w-3" />
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={channel.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline flex items-center gap-1"
|
||||
>
|
||||
{channel.link}
|
||||
<ExternalLinkIcon className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
<span>•</span>
|
||||
<span>Добавлен {formatDate(channel.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{channel.description && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Описание</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">{channel.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Подписчиков</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{channel.subscribers_count
|
||||
? formatCompactNumber(channel.subscribers_count)
|
||||
: "—"}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">В канале</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Всего закупов
|
||||
</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(channel.total_purchases)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
В этом канале
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Средний CPF</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
₽{formatMetric(channel.avg_cpf)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
По всем закупам
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Средний CPM</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
₽{formatMetric(channel.avg_cpm)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Стоимость 1000 просмотров
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>История закупов</CardTitle>
|
||||
<CardDescription>
|
||||
Все рекламные размещения в этом канале
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{channel.purchases.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
Закупов в этом канале пока нет
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Целевой канал</TableHead>
|
||||
<TableHead>Дата</TableHead>
|
||||
<TableHead className="text-right">Стоимость</TableHead>
|
||||
<TableHead className="text-right">Подписчики</TableHead>
|
||||
<TableHead className="text-right">CPF</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{channel.purchases.map((purchase) => (
|
||||
<TableRow key={purchase.id}>
|
||||
<TableCell className="font-medium">
|
||||
{purchase.target_channel.title}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDate(
|
||||
purchase.actual_date || purchase.scheduled_date
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(purchase.cost)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(purchase.subscriptions_count)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
₽{formatMetric(purchase.cpf)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/purchases/${purchase.id}`}>
|
||||
Открыть
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}, [router, resolvedParams.id]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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<File | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [result, setResult] = useState<ExternalChannelImportResponse | null>(
|
||||
null
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [parsedChannels, setParsedChannels] = useState<ParsedChannel[]>([]);
|
||||
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [currentChannel, setCurrentChannel] = useState<string>("");
|
||||
const [targetChannels, setTargetChannels] = useState<TargetChannel[]>([]);
|
||||
const [selectedTargetChannel, setSelectedTargetChannel] =
|
||||
useState<string>("");
|
||||
|
||||
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<HTMLInputElement>) => {
|
||||
@@ -87,33 +131,66 @@ export default function ImportExternalChannelsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return;
|
||||
const delay = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!selectedTargetChannel || parsedChannels.length === 0) return;
|
||||
|
||||
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 {
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
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],
|
||||
});
|
||||
|
||||
// Для примера используем первый целевой канал (tc1)
|
||||
// В реальном приложении нужно дать пользователю выбрать
|
||||
const importResult = await externalChannelsApi.import(file, "tc1");
|
||||
setResult(importResult);
|
||||
setFile(null);
|
||||
result.successful++;
|
||||
} catch (err: any) {
|
||||
setError(err?.error?.message || "Ошибка импорта файла");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
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() {
|
||||
Импорт внешних каналов
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Загрузите файл Excel с данными из Tgstat
|
||||
Загрузите файл Excel или CSV с данными каналов
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
@@ -143,195 +220,307 @@ export default function ImportExternalChannelsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Выбор целевого канала */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Формат файла</CardTitle>
|
||||
<CardTitle>Целевой канал</CardTitle>
|
||||
<CardDescription>
|
||||
Требования к структуре Excel файла
|
||||
Выберите целевой канал для привязки импортируемых каналов
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p>Файл должен содержать следующие колонки:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<li>
|
||||
<strong>Название</strong> - название канала
|
||||
</li>
|
||||
<li>
|
||||
<strong>Username</strong> - username канала (с @ или без)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Ссылка</strong> - ссылка на канал (t.me/...)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Подписчики</strong> - количество подписчиков (число)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Описание</strong> - краткое описание (опционально)
|
||||
</li>
|
||||
</ul>
|
||||
<div className="pt-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Скачать шаблон
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={selectedTargetChannel}
|
||||
onValueChange={setSelectedTargetChannel}
|
||||
disabled={isUploading || targetChannels.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите целевой канал" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetChannels.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.id}>
|
||||
{channel.title}
|
||||
{channel.username && ` (@${channel.username})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!result ? (
|
||||
{/* Шаблоны */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Скачать шаблон</CardTitle>
|
||||
<CardDescription>
|
||||
Используйте шаблон для правильного заполнения данных
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => downloadTemplate("xlsx")}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Шаблон Excel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => downloadTemplate("csv")}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Шаблон CSV
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Загрузка файла */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Загрузка файла</CardTitle>
|
||||
<CardDescription>
|
||||
Перетащите файл или выберите его
|
||||
Перетащите файл или нажмите для выбора
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent>
|
||||
<div
|
||||
className={`relative border-2 border-dashed rounded-lg p-12 text-center transition-colors ${
|
||||
isDragging
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-muted-foreground/25 hover:border-muted-foreground/50"
|
||||
} ${isUploading ? "opacity-50 pointer-events-none" : ""}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-12 text-center transition-colors
|
||||
${
|
||||
isDragging
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-muted-foreground/25"
|
||||
}
|
||||
${file ? "bg-muted/50" : ""}
|
||||
`}
|
||||
>
|
||||
{file ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<FileUp className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{file.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(file.size / 1024).toFixed(2)} KB
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Выбрать другой файл
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<FileUp className="h-12 w-12 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-medium">
|
||||
Перетащите файл сюда
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
или нажмите кнопку ниже
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="file-upload">
|
||||
<Button asChild variant="outline">
|
||||
<span>
|
||||
<FileUp className="mr-2 h-4 w-4" />
|
||||
Выбрать файл
|
||||
</span>
|
||||
</Button>
|
||||
</label>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
id="file-upload"
|
||||
className="hidden"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={handleFileInputChange}
|
||||
className="hidden"
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="cursor-pointer flex flex-col items-center"
|
||||
>
|
||||
<FileUp className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
{file ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{file.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(file.size / 1024).toFixed(2)} KB
|
||||
</p>
|
||||
{!isUploading && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleReset();
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Удалить
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Перетащите файл сюда или нажмите для выбора
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Поддерживаются форматы: .xlsx, .xls, .csv
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error && (
|
||||
{/* Результаты парсинга */}
|
||||
{parsedChannels.length > 0 && !importResult && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Результаты парсинга</CardTitle>
|
||||
<CardDescription>
|
||||
Найдено каналов: {parsedChannels.length}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{parseErrors.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
<AlertDescription>
|
||||
<strong>Ошибки при парсинге:</strong>
|
||||
<ul className="list-disc list-inside mt-2">
|
||||
{parseErrors.slice(0, 5).map((error, i) => (
|
||||
<li key={i} className="text-sm">
|
||||
{error}
|
||||
</li>
|
||||
))}
|
||||
{parseErrors.length > 5 && (
|
||||
<li className="text-sm">
|
||||
...и еще {parseErrors.length - 5}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{file && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={
|
||||
isUploading ||
|
||||
!selectedTargetChannel ||
|
||||
parsedChannels.length === 0
|
||||
}
|
||||
className="flex-1"
|
||||
>
|
||||
<FileSpreadsheet className="mr-2 h-4 w-4" />
|
||||
Импортировать {parsedChannels.length} каналов
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={isUploading}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={handleUpload} disabled={isUploading}>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Импорт...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileUp className="mr-2 h-4 w-4" />
|
||||
Импортировать
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{/* Прогресс импорта */}
|
||||
{isUploading && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-6 w-6 text-green-500" />
|
||||
<CardTitle>Импорт завершен</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Результаты обработки файла</CardDescription>
|
||||
<CardTitle>Импорт в процессе</CardTitle>
|
||||
<CardDescription>
|
||||
{currentChannel && `Импортируется: ${currentChannel}`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-green-50 dark:bg-green-950/20">
|
||||
<span className="text-sm font-medium">
|
||||
Импортировано каналов
|
||||
</span>
|
||||
<Badge variant="default">{result.imported}</Badge>
|
||||
</div>
|
||||
{result.skipped > 0 && (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-yellow-50 dark:bg-yellow-950/20">
|
||||
<span className="text-sm font-medium">Пропущено</span>
|
||||
<Badge variant="secondary">{result.skipped}</Badge>
|
||||
</div>
|
||||
<Progress value={progress} className="w-full" />
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
{Math.round(progress)}% завершено
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Результаты импорта */}
|
||||
{importResult && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Результаты импорта</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div className="p-4 border rounded-lg">
|
||||
<div className="text-2xl font-bold">{importResult.total}</div>
|
||||
<div className="text-xs text-muted-foreground">Всего</div>
|
||||
</div>
|
||||
<div className="p-4 border rounded-lg border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950">
|
||||
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
{importResult.successful}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Успешно</div>
|
||||
</div>
|
||||
<div className="p-4 border rounded-lg border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950">
|
||||
<div className="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||
{importResult.failed}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Ошибок</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.errors.length > 0 && (
|
||||
<Alert>
|
||||
{importResult.errors.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<p className="font-medium mb-2">
|
||||
Обнаружены ошибки при импорте:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm">
|
||||
{result.errors.map((error, index) => (
|
||||
<li key={index}>{error}</li>
|
||||
<strong>Ошибки импорта:</strong>
|
||||
<ul className="list-disc list-inside mt-2 max-h-60 overflow-y-auto">
|
||||
{importResult.errors.map((error, i) => (
|
||||
<li key={i} className="text-sm">
|
||||
{error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
{importResult.successful > 0 && (
|
||||
<Alert>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Успешно импортировано каналов: {importResult.successful}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => router.push("/external-channels")}
|
||||
className="flex-1"
|
||||
>
|
||||
Перейти к списку каналов
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
Импортировать еще
|
||||
</Button>
|
||||
<Button onClick={handleGoToChannels}>Перейти к каналам</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Инструкция */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Формат файла</CardTitle>
|
||||
<CardDescription>Требования к структуре файла</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>
|
||||
<strong>Колонки (в порядке):</strong>
|
||||
</p>
|
||||
<ol className="list-decimal list-inside space-y-1 ml-2">
|
||||
<li>
|
||||
<strong>Название канала*</strong> - обязательное поле
|
||||
</li>
|
||||
<li>
|
||||
<strong>Username</strong> - без символа @ (опционально)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Telegram ID</strong> - числовой ID канала
|
||||
(опционально)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Подписчиков</strong> - количество подписчиков
|
||||
(опционально)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Описание</strong> - описание канала (опционально)
|
||||
</li>
|
||||
</ol>
|
||||
<p className="text-muted-foreground mt-4">
|
||||
* Первая строка файла должна содержать заголовки и будет
|
||||
пропущена при импорте
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
<CardDescription className="truncate mt-1">
|
||||
{channel.username ? (
|
||||
<a
|
||||
href={channel.link}
|
||||
href={`https://t.me/${channel.username}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
@@ -325,14 +351,9 @@ export default function ExternalChannelsPage() {
|
||||
{formatUsername(channel.username)}
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={channel.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline text-xs"
|
||||
>
|
||||
{channel.link}
|
||||
</a>
|
||||
<span className="text-xs">
|
||||
ID: {channel.telegram_id}
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
@@ -345,8 +366,8 @@ export default function ExternalChannelsPage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-lg border bg-muted/50 p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 rounded-lg border bg-muted/50 p-2">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Users className="h-3 w-3 text-muted-foreground" />
|
||||
<div className="text-sm font-medium">
|
||||
@@ -355,44 +376,29 @@ export default function ExternalChannelsPage() {
|
||||
: "—"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
Подписчики
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/50 p-2">
|
||||
<div className="flex-1 rounded-lg border bg-muted/50 p-2 text-center">
|
||||
<div className="text-sm font-medium">
|
||||
{formatNumber(channel.total_purchases)}
|
||||
ID: {channel.telegram_id}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Закупов
|
||||
Telegram ID
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/50 p-2">
|
||||
<div className="text-sm font-medium">
|
||||
₽{formatMetric(channel.avg_cpf)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">CPF</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="default"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
<Link href={`/external-channels/${channel.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Подробнее
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(channel.id, channel.title)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -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<PurchaseDetail | null>(null);
|
||||
const [purchase, setPurchase] = useState<Placement | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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) {
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ShoppingCart className="h-6 w-6" />
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{purchase.external_channel.title}
|
||||
{purchase.external_channel_title}
|
||||
</h1>
|
||||
{purchase.is_archived ? (
|
||||
{purchase.status === "archived" ? (
|
||||
<Badge variant="secondary">Архив</Badge>
|
||||
) : purchase.actual_date ? (
|
||||
) : (
|
||||
<Badge variant="default">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Завершено
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
Запланировано
|
||||
Активно
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -197,33 +184,34 @@ export default function PurchaseDetailPage({ params }: PageProps) {
|
||||
<span>
|
||||
Целевой канал:{" "}
|
||||
<Link
|
||||
href={`/channels/${purchase.target_channel.id}`}
|
||||
href={`/channels`}
|
||||
className="hover:underline font-medium"
|
||||
>
|
||||
{purchase.target_channel.title}
|
||||
{purchase.target_channel_title}
|
||||
</Link>
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
Креатив:{" "}
|
||||
<Link
|
||||
href={`/creatives/${purchase.creative.id}`}
|
||||
href={`/creatives`}
|
||||
className="hover:underline font-medium"
|
||||
>
|
||||
{purchase.creative.name}
|
||||
{purchase.creative_name}
|
||||
</Link>
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
Дата размещения:{" "}
|
||||
{formatDate(purchase.actual_date || purchase.scheduled_date)}
|
||||
Дата размещения: {formatDate(purchase.placement_date)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleArchive}>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
{purchase.is_archived ? "Разархивировать" : "Архивировать"}
|
||||
{purchase.status === "archived"
|
||||
? "Разархивировать"
|
||||
: "Архивировать"}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
@@ -272,27 +260,45 @@ export default function PurchaseDetailPage({ params }: PageProps) {
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">CPF</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Статус просмотров
|
||||
</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{purchase.cpf ? `₽${formatMetric(purchase.cpf)}` : "—"}
|
||||
<div className="text-lg font-medium">
|
||||
{purchase.views_availability === "available"
|
||||
? "Доступны"
|
||||
: purchase.views_availability === "manual"
|
||||
? "Вручную"
|
||||
: purchase.views_availability === "unavailable"
|
||||
? "Недоступны"
|
||||
: "Неизвестно"}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{purchase.last_views_fetch_at
|
||||
? `Обновлено: ${formatDate(purchase.last_views_fetch_at)}`
|
||||
: "Еще не обновлялись"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Конверсия</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">Тип ссылки</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{purchase.conversion_rate
|
||||
? formatPercent(purchase.conversion_rate)
|
||||
: "—"}
|
||||
<div className="text-lg font-medium">
|
||||
{purchase.invite_link_type === "approval"
|
||||
? "С одобрением"
|
||||
: "Публичная"}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{purchase.invite_link_type === "approval"
|
||||
? "Отслеживание подписчиков"
|
||||
: "Без отслеживания"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -318,18 +324,18 @@ export default function PurchaseDetailPage({ params }: PageProps) {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{purchase.post_link && (
|
||||
{purchase.ad_post_url && (
|
||||
<div className="pt-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
Ссылка на рекламный пост:
|
||||
</Label>
|
||||
<a
|
||||
href={purchase.post_link}
|
||||
href={purchase.ad_post_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm hover:underline mt-1"
|
||||
>
|
||||
{purchase.post_link}
|
||||
{purchase.ad_post_url}
|
||||
<ExternalLinkIcon className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
@@ -363,127 +369,21 @@ export default function PurchaseDetailPage({ params }: PageProps) {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="subscriptions" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="subscriptions">
|
||||
Подписки ({purchase.subscriptions.length})
|
||||
</TabsTrigger>
|
||||
{purchase.views_history && purchase.views_history.length > 0 && (
|
||||
<TabsTrigger value="views">
|
||||
История просмотров ({purchase.views_history.length})
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="subscriptions" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Подписки</CardTitle>
|
||||
<CardTitle>Заметка</CardTitle>
|
||||
<CardDescription>
|
||||
Пользователи, перешедшие по ссылке
|
||||
Детальная информация о размещении доступна через раздел "Подписки"
|
||||
в главном меню
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{purchase.subscriptions.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
Подписок пока нет
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Для просмотра списка подписчиков и истории просмотров используйте
|
||||
соответствующие разделы в главном меню.
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Пользователь</TableHead>
|
||||
<TableHead>Username</TableHead>
|
||||
<TableHead>Дата подписки</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{purchase.subscriptions.map((subscription) => (
|
||||
<TableRow key={subscription.id}>
|
||||
<TableCell className="font-medium">
|
||||
{subscription.user_first_name}{" "}
|
||||
{subscription.user_last_name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{subscription.user_username
|
||||
? formatUsername(subscription.user_username)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDate(subscription.subscribed_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{subscription.is_active ? (
|
||||
<Badge variant="default">Активен</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Отписался</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{purchase.views_history && purchase.views_history.length > 0 && (
|
||||
<TabsContent value="views" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>История просмотров</CardTitle>
|
||||
<CardDescription>
|
||||
Динамика просмотров рекламного поста
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Дата</TableHead>
|
||||
<TableHead className="text-right">Просмотры</TableHead>
|
||||
<TableHead className="text-right">Прирост</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{purchase.views_history.map((item, index) => {
|
||||
const prevViews =
|
||||
index > 0
|
||||
? purchase.views_history![index - 1].views
|
||||
: 0;
|
||||
const growth = item.views - prevViews;
|
||||
|
||||
return (
|
||||
<TableRow key={item.fetched_at}>
|
||||
<TableCell>{formatDate(item.fetched_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(item.views)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{index > 0 && (
|
||||
<span
|
||||
className={
|
||||
growth > 0
|
||||
? "text-green-600"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
+{formatNumber(growth)}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scheduled_date">Дата размещения *</Label>
|
||||
<Label htmlFor="placement_date">Дата размещения *</Label>
|
||||
<Input
|
||||
id="scheduled_date"
|
||||
type="date"
|
||||
value={formData.scheduled_date}
|
||||
id="placement_date"
|
||||
type="datetime-local"
|
||||
value={formData.placement_date}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
scheduled_date: e.target.value,
|
||||
placement_date: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -443,7 +452,7 @@ export default function CreatePurchasePage() {
|
||||
<Label>Тип пригласительной ссылки *</Label>
|
||||
<RadioGroup
|
||||
value={formData.invite_link_type}
|
||||
onValueChange={(value: "public" | "private") =>
|
||||
onValueChange={(value: "public" | "approval") =>
|
||||
setFormData({ ...formData, invite_link_type: value })
|
||||
}
|
||||
>
|
||||
@@ -454,8 +463,8 @@ export default function CreatePurchasePage() {
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="private" id="private" />
|
||||
<Label htmlFor="private" className="font-normal">
|
||||
<RadioGroupItem value="approval" id="approval" />
|
||||
<Label htmlFor="approval" className="font-normal">
|
||||
С одобрением (запрос на вступление через бота)
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
@@ -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)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{stats.completed} завершено, {stats.scheduled} запланировано
|
||||
{stats.active} активных, {stats.archived} в архиве
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -203,20 +197,6 @@ export default function PurchasesPage() {
|
||||
<p className="text-xs text-muted-foreground mt-1">Всего</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Средний CPF</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
₽{formatMetric(stats.avgCpf)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
По всем закупам
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
@@ -308,9 +288,8 @@ export default function PurchasesPage() {
|
||||
<TableHead>Креатив</TableHead>
|
||||
<TableHead>Дата</TableHead>
|
||||
<TableHead className="text-right">Стоимость</TableHead>
|
||||
<TableHead className="text-right">Подписчики</TableHead>
|
||||
<TableHead className="text-right">CPF</TableHead>
|
||||
<TableHead className="text-right">Конверсия</TableHead>
|
||||
<TableHead className="text-right">Подписки</TableHead>
|
||||
<TableHead className="text-right">Просмотры</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -318,34 +297,27 @@ export default function PurchasesPage() {
|
||||
{filteredPurchases.map((purchase) => (
|
||||
<TableRow key={purchase.id}>
|
||||
<TableCell>
|
||||
{purchase.is_archived ? (
|
||||
{purchase.status === "archived" ? (
|
||||
<Badge variant="secondary">
|
||||
<Archive className="h-3 w-3 mr-1" />
|
||||
Архив
|
||||
</Badge>
|
||||
) : purchase.actual_date ? (
|
||||
) : (
|
||||
<Badge variant="default">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Завершено
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
Запланировано
|
||||
Активно
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{purchase.target_channel.title}
|
||||
{purchase.target_channel_title}
|
||||
</TableCell>
|
||||
<TableCell>{purchase.external_channel.title}</TableCell>
|
||||
<TableCell>{purchase.external_channel_title}</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">
|
||||
{purchase.creative.name}
|
||||
{purchase.creative_name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDate(
|
||||
purchase.actual_date || purchase.scheduled_date
|
||||
)}
|
||||
{formatDate(purchase.placement_date)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(purchase.cost)}
|
||||
@@ -354,11 +326,8 @@ export default function PurchasesPage() {
|
||||
{formatNumber(purchase.subscriptions_count)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{purchase.cpf ? `₽${formatMetric(purchase.cpf)}` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{purchase.conversion_rate
|
||||
? formatPercent(purchase.conversion_rate)
|
||||
{purchase.views_count
|
||||
? formatNumber(purchase.views_count)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
|
||||
@@ -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<AuthProviderProps> = ({ 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 {
|
||||
|
||||
@@ -143,13 +143,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
{user && (
|
||||
<NavUser
|
||||
user={{
|
||||
name: `${user.first_name}${
|
||||
user.last_name ? " " + user.last_name : ""
|
||||
}`,
|
||||
name: user.username || `User ${user.telegram_id}`,
|
||||
email: user.username
|
||||
? `@${user.username}`
|
||||
: `ID: ${user.telegram_id}`,
|
||||
avatar: `https://ui-avatars.com/api/?name=${user.first_name}`,
|
||||
avatar: user.username
|
||||
? `https://ui-avatars.com/api/?name=${user.username}`
|
||||
: `https://ui-avatars.com/api/?name=User`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
31
components/ui/progress.tsx
Normal file
31
components/ui/progress.tsx
Normal file
@@ -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<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
@@ -22,22 +22,18 @@ export const authApi = {
|
||||
/**
|
||||
* Complete auth with one-time token from bot
|
||||
*/
|
||||
complete: async (token: string): Promise<AuthCompleteResponse> => {
|
||||
const data: AuthCompleteRequest = { token };
|
||||
const response = await api.post<AuthCompleteResponse>(
|
||||
"/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;
|
||||
|
||||
@@ -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<ListResponse<TargetChannel>>("/target-channels", { params }),
|
||||
|
||||
/**
|
||||
* Get target channel details
|
||||
*/
|
||||
get: (id: string) => api.get<TargetChannelDetail>(`/target-channels/${id}`),
|
||||
|
||||
/**
|
||||
* Update target channel (is_active)
|
||||
*/
|
||||
update: (id: string, data: TargetChannelUpdateRequest) =>
|
||||
api.patch<TargetChannel>(`/target-channels/${id}`, data),
|
||||
list: () => api.get<TargetChannelsListResponse>("/target_channels"),
|
||||
|
||||
/**
|
||||
* Delete (disconnect) target channel
|
||||
* DELETE /api/v1/target_channels/{channel_id}
|
||||
*/
|
||||
delete: (id: string) => api.delete<SuccessResponse>(`/target-channels/${id}`),
|
||||
delete: (id: string) => api.delete<SuccessResponse>(`/target_channels/${id}`),
|
||||
};
|
||||
|
||||
@@ -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 <T = any>(
|
||||
|
||||
// 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<T> => {
|
||||
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();
|
||||
|
||||
|
||||
@@ -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<ListResponse<Creative>>("/creatives", { params }),
|
||||
api.get<CreativesListResponse>("/creatives", { params }),
|
||||
|
||||
/**
|
||||
* Get creative details
|
||||
*/
|
||||
get: (id: string) => api.get<CreativeDetail>(`/creatives/${id}`),
|
||||
get: (id: string) => api.get<Creative>(`/creatives/${id}`),
|
||||
|
||||
/**
|
||||
* Create new creative
|
||||
|
||||
@@ -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<ListResponse<ExternalChannel>>("/external-channels", { params }),
|
||||
|
||||
/**
|
||||
* Get external channel details
|
||||
*/
|
||||
get: (id: string) =>
|
||||
api.get<ExternalChannelDetail>(`/external-channels/${id}`),
|
||||
list: (targetChannelId: string) =>
|
||||
api.get<ExternalChannelsListResponse>(
|
||||
`/external_channels/target/${targetChannelId}`
|
||||
),
|
||||
|
||||
/**
|
||||
* Create new external channel
|
||||
* POST /api/v1/external_channels
|
||||
*/
|
||||
create: (data: ExternalChannelCreateRequest) =>
|
||||
api.post<ExternalChannel>("/external-channels", data),
|
||||
api.post<ExternalChannel>("/external_channels", data),
|
||||
|
||||
/**
|
||||
* Update external channel
|
||||
* PATCH /api/v1/external_channels/{channel_id}
|
||||
*/
|
||||
update: (id: string, data: ExternalChannelUpdateRequest) =>
|
||||
api.patch<ExternalChannel>(`/external-channels/${id}`, data),
|
||||
api.patch<ExternalChannel>(`/external_channels/${id}`, data),
|
||||
|
||||
/**
|
||||
* Update external channel links
|
||||
* PATCH /api/v1/external_channels/{channel_id}/links
|
||||
*/
|
||||
updateLinks: (id: string, data: ExternalChannelLinksUpdateRequest) =>
|
||||
api.patch<ExternalChannel>(`/external_channels/${id}/links`, data),
|
||||
|
||||
/**
|
||||
* Delete external channel
|
||||
* DELETE /api/v1/external_channels/{channel_id}
|
||||
*/
|
||||
delete: (id: string) =>
|
||||
api.delete<SuccessResponse>(`/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<ExternalChannelImportResponse>(
|
||||
"/external-channels/import",
|
||||
formData
|
||||
);
|
||||
},
|
||||
api.delete<SuccessResponse>(`/external_channels/${id}`),
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
73
lib/api/placements.ts
Normal file
73
lib/api/placements.ts
Normal file
@@ -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<PlacementsListResponse>("/placements", { params }),
|
||||
|
||||
/**
|
||||
* Get placement details
|
||||
*/
|
||||
get: (id: string) => api.get<Placement>(`/placements/${id}`),
|
||||
|
||||
/**
|
||||
* Create new placement
|
||||
*/
|
||||
create: (data: PlacementCreateRequest) =>
|
||||
api.post<Placement>("/placements", data),
|
||||
|
||||
/**
|
||||
* Update placement
|
||||
*/
|
||||
update: (id: string, data: PlacementUpdateRequest) =>
|
||||
api.patch<Placement>(`/placements/${id}`, data),
|
||||
|
||||
/**
|
||||
* Delete placement
|
||||
*/
|
||||
delete: (id: string) => api.delete<SuccessResponse>(`/placements/${id}`),
|
||||
|
||||
/**
|
||||
* Fetch views for placement
|
||||
*/
|
||||
fetchViews: (id: string) =>
|
||||
api.post<ViewsFetchResponse>(`/placements/${id}/views/fetch`),
|
||||
|
||||
/**
|
||||
* Get views history for placement
|
||||
*/
|
||||
viewsHistory: (
|
||||
id: string,
|
||||
params?: { from_date?: string; to_date?: string }
|
||||
) =>
|
||||
api.get<ViewsHistoryResponse>(`/placements/${id}/views/history`, {
|
||||
params,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Set views manually
|
||||
*/
|
||||
setViewsManually: (id: string, views_count: number) =>
|
||||
api.post<Placement>(`/placements/${id}/views/manual`, null, {
|
||||
params: { views_count },
|
||||
}),
|
||||
};
|
||||
|
||||
// Keep purchasesApi as an alias for backwards compatibility
|
||||
export const purchasesApi = placementsApi;
|
||||
@@ -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<ListResponse<Purchase>>("/purchases", { params }),
|
||||
|
||||
/**
|
||||
* Get purchase details
|
||||
*/
|
||||
get: (id: string) => api.get<PurchaseDetail>(`/purchases/${id}`),
|
||||
|
||||
/**
|
||||
* Create new purchase
|
||||
*/
|
||||
create: (data: PurchaseCreateRequest) =>
|
||||
api.post<PurchaseCreateResponse>("/purchases", data),
|
||||
|
||||
/**
|
||||
* Update purchase
|
||||
*/
|
||||
update: (id: string, data: PurchaseUpdateRequest) =>
|
||||
api.patch<Purchase>(`/purchases/${id}`, data),
|
||||
|
||||
/**
|
||||
* Delete purchase
|
||||
*/
|
||||
delete: (id: string) => api.delete<SuccessResponse>(`/purchases/${id}`),
|
||||
|
||||
/**
|
||||
* Refresh views count for purchase
|
||||
*/
|
||||
refreshViews: (id: string) =>
|
||||
api.post<PurchaseRefreshViewsResponse>(`/purchases/${id}/refresh-views`),
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
213
lib/mocks/data/placements.ts
Normal file
213
lib/mocks/data/placements.ts
Normal file
@@ -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",
|
||||
},
|
||||
];
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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<AuthCompleteResponse> {
|
||||
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<ListResponse<TargetChannel>> {
|
||||
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<TargetChannel> {
|
||||
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<ListResponse<ExternalChannel>> {
|
||||
async getExternalChannels(
|
||||
targetChannelId: string
|
||||
): Promise<ExternalChannelsListResponse> {
|
||||
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<ExternalChannel> {
|
||||
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<ExternalChannelImportResponse> {
|
||||
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<ExternalChannel> {
|
||||
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<ListResponse<Creative>> {
|
||||
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<Creative> {
|
||||
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<Creative> {
|
||||
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<ListResponse<Purchase>> {
|
||||
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<Placement> {
|
||||
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<PurchaseCreateResponse> {
|
||||
async createPlacement(data: PlacementCreateRequest): Promise<Placement> {
|
||||
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<Purchase> {
|
||||
data: PlacementUpdateRequest
|
||||
): Promise<Placement> {
|
||||
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<SuccessResponse> {
|
||||
async deletePlacement(id: string): Promise<SuccessResponse> {
|
||||
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<PurchaseRefreshViewsResponse> {
|
||||
async fetchPlacementViews(id: string): Promise<ViewsFetchResponse> {
|
||||
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<ViewsHistoryResponse> {
|
||||
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<Placement> {
|
||||
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(),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
198
lib/types/api.ts
198
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;
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
191
lib/utils/file-parser.ts
Normal file
191
lib/utils/file-parser.ts
Normal file
@@ -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<ParseResult> => {
|
||||
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<ParseResult> => {
|
||||
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<ParseResult> => {
|
||||
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();
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
116
pnpm-lock.yaml
generated
116
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user