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);
|
}, 1500);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setStatus("error");
|
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([
|
const [reportData, channelsData] = await Promise.all([
|
||||||
analyticsApi.costs({ period, target_channel_id: targetChannelId }),
|
analyticsApi.costs({ period, target_channel_id: targetChannelId }),
|
||||||
channels.length > 0
|
channels.length > 0
|
||||||
? Promise.resolve({ data: channels })
|
? Promise.resolve({ target_channels: channels })
|
||||||
: channelsApi.list({}),
|
: channelsApi.list(),
|
||||||
]);
|
]);
|
||||||
setReport(reportData);
|
setReport(reportData);
|
||||||
if (channels.length === 0) {
|
if (channels.length === 0) {
|
||||||
setChannels(channelsData.data);
|
setChannels(channelsData.target_channels);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.error?.message || "Ошибка загрузки аналитики");
|
setError(err?.error?.message || "Ошибка загрузки аналитики");
|
||||||
|
|||||||
@@ -4,357 +4,23 @@
|
|||||||
// Target Channel Detail Page
|
// Target Channel Detail Page
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { use } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
import { use } from "react";
|
||||||
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";
|
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ChannelDetailPage({ params }: PageProps) {
|
export default function ChannelDetailPage({ params }: PageProps) {
|
||||||
const resolvedParams = use(params);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [channel, setChannel] = useState<TargetChannelDetail | null>(null);
|
const resolvedParams = use(params);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadChannel = async () => {
|
// Redirect back to channels list
|
||||||
try {
|
// Detail page functionality not yet implemented in backend API
|
||||||
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);
|
|
||||||
router.push("/channels");
|
router.push("/channels");
|
||||||
} catch (err: any) {
|
}, [router, resolvedParams.id]);
|
||||||
alert(err?.error?.message || "Ошибка удаления канала");
|
|
||||||
}
|
return null;
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,9 +45,11 @@ export default function ChannelsPage() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await channelsApi.list();
|
const response = await channelsApi.list();
|
||||||
setChannels(response.data);
|
setChannels(response.target_channels);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.error?.message || "Ошибка загрузки каналов");
|
setError(
|
||||||
|
err?.error?.message || err?.detail || "Ошибка загрузки каналов"
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -62,14 +64,16 @@ export default function ChannelsPage() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleToggleActive = async (id: string, isActive: boolean) => {
|
const handleDisconnect = async (id: string, title: string) => {
|
||||||
|
if (!confirm(`Вы уверены, что хотите отключить канал "${title}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await channelsApi.update(id, { is_active: !isActive });
|
await channelsApi.delete(id);
|
||||||
setChannels((prev) =>
|
setChannels((prev) => prev.filter((ch) => ch.id !== id));
|
||||||
prev.map((ch) => (ch.id === id ? { ...ch, is_active: !isActive } : ch))
|
|
||||||
);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err?.error?.message || "Ошибка обновления канала");
|
alert(err?.error?.message || err?.detail || "Ошибка отключения канала");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -213,59 +217,23 @@ export default function ChannelsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{channel.description && (
|
<div className="text-sm text-muted-foreground">
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
Telegram ID:{" "}
|
||||||
{channel.description}
|
<code className="text-xs bg-muted px-1 rounded">
|
||||||
</p>
|
{channel.telegram_id}
|
||||||
)}
|
</code>
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2">
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleToggleActive(channel.id, channel.is_active)
|
handleDisconnect(channel.id, channel.title)
|
||||||
}
|
}
|
||||||
|
className="flex-1"
|
||||||
>
|
>
|
||||||
{channel.is_active ? "Отключить" : "Включить"}
|
Отключить канал
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { CreativeDetail } from "@/lib/types/api";
|
import type { Creative } from "@/lib/types/api";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -54,7 +54,7 @@ interface PageProps {
|
|||||||
export default function CreativeDetailPage({ params }: PageProps) {
|
export default function CreativeDetailPage({ params }: PageProps) {
|
||||||
const resolvedParams = use(params);
|
const resolvedParams = use(params);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [creative, setCreative] = useState<CreativeDetail | null>(null);
|
const [creative, setCreative] = useState<Creative | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -79,9 +79,12 @@ export default function CreativeDetailPage({ params }: PageProps) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await creativesApi.update(creative.id, {
|
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) {
|
} catch (err: any) {
|
||||||
alert(err?.error?.message || "Ошибка обновления креатива");
|
alert(err?.error?.message || "Ошибка обновления креатива");
|
||||||
}
|
}
|
||||||
@@ -162,16 +165,20 @@ export default function CreativeDetailPage({ params }: PageProps) {
|
|||||||
<h1 className="text-3xl font-bold tracking-tight">
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
{creative.name}
|
{creative.name}
|
||||||
</h1>
|
</h1>
|
||||||
{creative.is_archived && <Badge variant="secondary">Архив</Badge>}
|
{creative.status === "archived" && (
|
||||||
|
<Badge variant="secondary">Архив</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Целевой канал: {creative.target_channel.title}
|
Целевой канал: {creative.target_channel_title}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" onClick={handleArchive}>
|
<Button variant="outline" onClick={handleArchive}>
|
||||||
<Archive className="mr-2 h-4 w-4" />
|
<Archive className="mr-2 h-4 w-4" />
|
||||||
{creative.is_archived ? "Разархивировать" : "Архивировать"}
|
{creative.status === "archived"
|
||||||
|
? "Разархивировать"
|
||||||
|
: "Архивировать"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={handleDelete}>
|
<Button variant="destructive" onClick={handleDelete}>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
@@ -190,10 +197,10 @@ export default function CreativeDetailPage({ params }: PageProps) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-2xl font-bold">
|
||||||
{formatNumber(creative.total_purchases)}
|
{formatNumber(creative.placements_count)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
С этим креативом
|
Всего размещений
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -201,29 +208,16 @@ export default function CreativeDetailPage({ params }: PageProps) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">
|
<CardTitle className="text-sm font-medium">
|
||||||
Подписчиков привлечено
|
Целевой канал
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-lg font-medium">
|
||||||
{formatNumber(creative.total_subscriptions)}
|
{creative.target_channel_title}
|
||||||
</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>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
По всем закупам
|
Канал для рекламы
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -261,58 +255,29 @@ export default function CreativeDetailPage({ params }: PageProps) {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Закупы с этим креативом</CardTitle>
|
<CardTitle>Информация о размещениях</CardTitle>
|
||||||
<CardDescription>История использования креатива</CardDescription>
|
<CardDescription>Статистика использования креатива</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{creative.purchases.length === 0 ? (
|
<div className="space-y-4">
|
||||||
<p className="text-center text-muted-foreground py-8">
|
<div>
|
||||||
Закупов с этим креативом пока нет
|
<h4 className="text-sm font-medium mb-2">Всего размещений:</h4>
|
||||||
|
<p className="text-2xl font-bold">
|
||||||
|
{creative.placements_count}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
</div>
|
||||||
<Table>
|
<div>
|
||||||
<TableHeader>
|
<h4 className="text-sm font-medium mb-2">Создан:</h4>
|
||||||
<TableRow>
|
<p className="text-sm">{formatDate(creative.created_at)}</p>
|
||||||
<TableHead>Внешний канал</TableHead>
|
</div>
|
||||||
<TableHead>Дата</TableHead>
|
<div>
|
||||||
<TableHead className="text-right">Стоимость</TableHead>
|
<Button asChild variant="outline" className="w-full">
|
||||||
<TableHead className="text-right">Подписчики</TableHead>
|
<Link href={`/purchases?creative_id=${creative.id}`}>
|
||||||
<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}`}>
|
|
||||||
Открыть
|
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</div>
|
||||||
</TableRow>
|
</div>
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ export default function CreateCreativePage() {
|
|||||||
|
|
||||||
const loadChannels = async () => {
|
const loadChannels = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await channelsApi.list({ is_active: true });
|
const response = await channelsApi.list();
|
||||||
setChannels(response.data);
|
setChannels(response.target_channels.filter((c) => c.is_active));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error loading channels:", err);
|
console.error("Error loading channels:", err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { creativesApi } from "@/lib/api";
|
import { creativesApi } from "@/lib/api";
|
||||||
import { formatNumber, formatMetric, truncate } from "@/lib/utils/format";
|
import {
|
||||||
|
formatNumber,
|
||||||
|
formatMetric,
|
||||||
|
truncate,
|
||||||
|
formatDate,
|
||||||
|
} from "@/lib/utils/format";
|
||||||
import {
|
import {
|
||||||
Folder,
|
Folder,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -48,7 +53,7 @@ export default function CreativesPage() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await creativesApi.list();
|
const response = await creativesApi.list();
|
||||||
setCreatives(response.data);
|
setCreatives(response.creatives);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.error?.message || "Ошибка загрузки креативов");
|
setError(err?.error?.message || "Ошибка загрузки креативов");
|
||||||
} finally {
|
} 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 {
|
try {
|
||||||
await creativesApi.update(id, { is_archived: !isArchived });
|
const newStatus = currentStatus === "active" ? "archived" : "active";
|
||||||
|
await creativesApi.update(id, { status: newStatus });
|
||||||
loadCreatives();
|
loadCreatives();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err?.error?.message || "Ошибка обновления креатива");
|
alert(err?.error?.message || "Ошибка обновления креатива");
|
||||||
@@ -77,8 +86,8 @@ export default function CreativesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const filteredCreatives = creatives.filter((creative) => {
|
const filteredCreatives = creatives.filter((creative) => {
|
||||||
if (activeTab === "active") return !creative.is_archived;
|
if (activeTab === "active") return creative.status === "active";
|
||||||
if (activeTab === "archived") return creative.is_archived;
|
if (activeTab === "archived") return creative.status === "archived";
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,10 +126,10 @@ export default function CreativesPage() {
|
|||||||
>
|
>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="active">
|
<TabsTrigger value="active">
|
||||||
Активные ({creatives.filter((c) => !c.is_archived).length})
|
Активные ({creatives.filter((c) => c.status === "active").length})
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="archived">
|
<TabsTrigger value="archived">
|
||||||
Архив ({creatives.filter((c) => c.is_archived).length})
|
Архив ({creatives.filter((c) => c.status === "archived").length})
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="all">Все ({creatives.length})</TabsTrigger>
|
<TabsTrigger value="all">Все ({creatives.length})</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -166,12 +175,12 @@ export default function CreativesPage() {
|
|||||||
<CardTitle className="truncate text-base">
|
<CardTitle className="truncate text-base">
|
||||||
{creative.name}
|
{creative.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{creative.is_archived && (
|
{creative.status === "archived" && (
|
||||||
<Badge variant="secondary">Архив</Badge>
|
<Badge variant="secondary">Архив</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="text-xs">
|
<CardDescription className="text-xs">
|
||||||
{creative.target_channel.title}
|
{creative.target_channel_title}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Folder className="h-5 w-5 text-muted-foreground shrink-0" />
|
<Folder className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||||
@@ -184,29 +193,21 @@ export default function CreativesPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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="rounded-lg border bg-muted/50 p-2">
|
||||||
<div className="text-sm font-medium">
|
<div className="text-sm font-medium">
|
||||||
{formatNumber(creative.total_purchases)}
|
{formatNumber(creative.placements_count)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Закупов
|
Размещений
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border bg-muted/50 p-2">
|
<div className="rounded-lg border bg-muted/50 p-2">
|
||||||
<div className="text-sm font-medium">
|
<div className="text-xs font-medium">
|
||||||
{formatNumber(creative.total_subscriptions)}
|
{formatDate(creative.created_at)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,7 +228,7 @@ export default function CreativesPage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleArchive(creative.id, creative.is_archived)
|
handleArchive(creative.id, creative.status)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Archive className="h-4 w-4" />
|
<Archive className="h-4 w-4" />
|
||||||
|
|||||||
@@ -4,317 +4,23 @@
|
|||||||
// External Channel Detail Page
|
// External Channel Detail Page
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { use } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
import { use } from "react";
|
||||||
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";
|
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExternalChannelDetailPage({ params }: PageProps) {
|
export default function ExternalChannelDetailPage({ params }: PageProps) {
|
||||||
const resolvedParams = use(params);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [channel, setChannel] = useState<ExternalChannelDetail | null>(null);
|
const resolvedParams = use(params);
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadChannel = async () => {
|
// Redirect back to external channels list
|
||||||
try {
|
// Detail page functionality not yet implemented in backend API
|
||||||
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);
|
|
||||||
router.push("/external-channels");
|
router.push("/external-channels");
|
||||||
} catch (err: any) {
|
}, [router, resolvedParams.id]);
|
||||||
alert(err?.error?.message || "Ошибка удаления канала");
|
|
||||||
}
|
return null;
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// External Channels Import Page
|
// External Channels Import Page
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||||
@@ -17,8 +17,20 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { externalChannelsApi } from "@/lib/api";
|
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 {
|
import {
|
||||||
FileUp,
|
FileUp,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -27,18 +39,46 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
X,
|
X,
|
||||||
|
FileSpreadsheet,
|
||||||
} from "lucide-react";
|
} 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() {
|
export default function ImportExternalChannelsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [result, setResult] = useState<ExternalChannelImportResponse | null>(
|
const [parsedChannels, setParsedChannels] = useState<ParsedChannel[]>([]);
|
||||||
null
|
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||||
);
|
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||||
const [error, setError] = useState<string | 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) => {
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -59,25 +99,29 @@ export default function ImportExternalChannelsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileSelect = (selectedFile: File) => {
|
const handleFileSelect = async (selectedFile: File) => {
|
||||||
// Проверка типа файла
|
if (!selectedFile.name.match(/\.(xlsx|xls|csv)$/i)) {
|
||||||
const validTypes = [
|
setParseErrors(["Пожалуйста, выберите файл Excel (.xlsx, .xls) или CSV"]);
|
||||||
"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");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setFile(selectedFile);
|
setFile(selectedFile);
|
||||||
setError(null);
|
setParseErrors([]);
|
||||||
setResult(null);
|
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>) => {
|
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -87,33 +131,66 @@ export default function ImportExternalChannelsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpload = async () => {
|
const delay = (ms: number) =>
|
||||||
if (!file) return;
|
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 {
|
try {
|
||||||
setIsUploading(true);
|
await externalChannelsApi.create({
|
||||||
setError(null);
|
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)
|
result.successful++;
|
||||||
// В реальном приложении нужно дать пользователю выбрать
|
|
||||||
const importResult = await externalChannelsApi.import(file, "tc1");
|
|
||||||
setResult(importResult);
|
|
||||||
setFile(null);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.error?.message || "Ошибка импорта файла");
|
result.failed++;
|
||||||
} finally {
|
const errorMsg =
|
||||||
setIsUploading(false);
|
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 = () => {
|
const handleReset = () => {
|
||||||
setFile(null);
|
setFile(null);
|
||||||
setResult(null);
|
setParsedChannels([]);
|
||||||
setError(null);
|
setParseErrors([]);
|
||||||
};
|
setImportResult(null);
|
||||||
|
setProgress(0);
|
||||||
const handleGoToChannels = () => {
|
|
||||||
router.push("/external-channels");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -132,7 +209,7 @@ export default function ImportExternalChannelsPage() {
|
|||||||
Импорт внешних каналов
|
Импорт внешних каналов
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
Загрузите файл Excel с данными из Tgstat
|
Загрузите файл Excel или CSV с данными каналов
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
@@ -143,195 +220,307 @@ export default function ImportExternalChannelsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Выбор целевого канала */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Формат файла</CardTitle>
|
<CardTitle>Целевой канал</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Требования к структуре Excel файла
|
Выберите целевой канал для привязки импортируемых каналов
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3 text-sm">
|
<Select
|
||||||
<p>Файл должен содержать следующие колонки:</p>
|
value={selectedTargetChannel}
|
||||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
onValueChange={setSelectedTargetChannel}
|
||||||
<li>
|
disabled={isUploading || targetChannels.length === 0}
|
||||||
<strong>Название</strong> - название канала
|
>
|
||||||
</li>
|
<SelectTrigger>
|
||||||
<li>
|
<SelectValue placeholder="Выберите целевой канал" />
|
||||||
<strong>Username</strong> - username канала (с @ или без)
|
</SelectTrigger>
|
||||||
</li>
|
<SelectContent>
|
||||||
<li>
|
{targetChannels.map((channel) => (
|
||||||
<strong>Ссылка</strong> - ссылка на канал (t.me/...)
|
<SelectItem key={channel.id} value={channel.id}>
|
||||||
</li>
|
{channel.title}
|
||||||
<li>
|
{channel.username && ` (@${channel.username})`}
|
||||||
<strong>Подписчики</strong> - количество подписчиков (число)
|
</SelectItem>
|
||||||
</li>
|
))}
|
||||||
<li>
|
</SelectContent>
|
||||||
<strong>Описание</strong> - краткое описание (опционально)
|
</Select>
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div className="pt-2">
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Скачать шаблон
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Загрузка файла</CardTitle>
|
<CardTitle>Загрузка файла</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Перетащите файл или выберите его
|
Перетащите файл или нажмите для выбора
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent>
|
||||||
<div
|
<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}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
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
|
<input
|
||||||
id="file-upload"
|
|
||||||
type="file"
|
type="file"
|
||||||
|
id="file-upload"
|
||||||
|
className="hidden"
|
||||||
accept=".xlsx,.xls,.csv"
|
accept=".xlsx,.xls,.csv"
|
||||||
onChange={handleFileInputChange}
|
onChange={handleFileInputChange}
|
||||||
className="hidden"
|
disabled={isUploading}
|
||||||
/>
|
/>
|
||||||
</div>
|
<label
|
||||||
</div>
|
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>
|
</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">
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<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>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{file && (
|
<div className="flex gap-2">
|
||||||
<div className="flex justify-end gap-2">
|
<Button
|
||||||
<Button variant="outline" onClick={handleReset}>
|
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>
|
||||||
<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>
|
</div>
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
)}
|
||||||
|
|
||||||
|
{/* Прогресс импорта */}
|
||||||
|
{isUploading && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<CardTitle>Импорт в процессе</CardTitle>
|
||||||
<CheckCircle className="h-6 w-6 text-green-500" />
|
<CardDescription>
|
||||||
<CardTitle>Импорт завершен</CardTitle>
|
{currentChannel && `Импортируется: ${currentChannel}`}
|
||||||
</div>
|
</CardDescription>
|
||||||
<CardDescription>Результаты обработки файла</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-2">
|
<Progress value={progress} className="w-full" />
|
||||||
<div className="flex items-center justify-between p-3 rounded-lg bg-green-50 dark:bg-green-950/20">
|
<p className="text-sm text-center text-muted-foreground">
|
||||||
<span className="text-sm font-medium">
|
{Math.round(progress)}% завершено
|
||||||
Импортировано каналов
|
</p>
|
||||||
</span>
|
</CardContent>
|
||||||
<Badge variant="default">{result.imported}</Badge>
|
</Card>
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Результаты импорта */}
|
||||||
|
{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>
|
</div>
|
||||||
|
|
||||||
{result.errors.length > 0 && (
|
{importResult.errors.length > 0 && (
|
||||||
<Alert>
|
<Alert variant="destructive">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<p className="font-medium mb-2">
|
<strong>Ошибки импорта:</strong>
|
||||||
Обнаружены ошибки при импорте:
|
<ul className="list-disc list-inside mt-2 max-h-60 overflow-y-auto">
|
||||||
</p>
|
{importResult.errors.map((error, i) => (
|
||||||
<ul className="list-disc list-inside space-y-1 text-sm">
|
<li key={i} className="text-sm">
|
||||||
{result.errors.map((error, index) => (
|
{error}
|
||||||
<li key={index}>{error}</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</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 variant="outline" onClick={handleReset}>
|
||||||
Импортировать еще
|
Импортировать еще
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleGoToChannels}>Перейти к каналам</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { externalChannelsApi } from "@/lib/api";
|
import { externalChannelsApi, channelsApi } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
formatNumber,
|
formatNumber,
|
||||||
formatMetric,
|
formatMetric,
|
||||||
@@ -74,10 +74,24 @@ export default function ExternalChannelsPage() {
|
|||||||
const loadChannels = async () => {
|
const loadChannels = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await externalChannelsApi.list();
|
// Сначала загружаем target channels
|
||||||
setChannels(response.data);
|
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) {
|
} catch (err: any) {
|
||||||
setError(err?.error?.message || "Ошибка загрузки каналов");
|
const errorMsg =
|
||||||
|
typeof err?.detail === "string"
|
||||||
|
? err.detail
|
||||||
|
: err?.error?.message || "Ошибка загрузки каналов";
|
||||||
|
setError(errorMsg);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -86,15 +100,23 @@ export default function ExternalChannelsPage() {
|
|||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
try {
|
try {
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
|
// Получаем первый доступный target channel
|
||||||
|
const targetChannelsRes = await channelsApi.list();
|
||||||
|
if (targetChannelsRes.target_channels.length === 0) {
|
||||||
|
alert("Добавьте сначала целевой канал");
|
||||||
|
setIsCreating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await externalChannelsApi.create({
|
await externalChannelsApi.create({
|
||||||
|
telegram_id: 0, // Временно, пока не получим реальный ID
|
||||||
title: formData.title,
|
title: formData.title,
|
||||||
username: formData.username || undefined,
|
username: formData.username || null,
|
||||||
link: formData.link,
|
|
||||||
subscribers_count: formData.subscribers_count
|
subscribers_count: formData.subscribers_count
|
||||||
? parseInt(formData.subscribers_count)
|
? parseInt(formData.subscribers_count)
|
||||||
: undefined,
|
: null,
|
||||||
description: formData.description || undefined,
|
description: formData.description || null,
|
||||||
target_channel_ids: [], // По умолчанию не привязываем
|
target_channel_ids: [targetChannelsRes.target_channels[0].id],
|
||||||
});
|
});
|
||||||
setIsCreateDialogOpen(false);
|
setIsCreateDialogOpen(false);
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -106,7 +128,11 @@ export default function ExternalChannelsPage() {
|
|||||||
});
|
});
|
||||||
loadChannels();
|
loadChannels();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err?.error?.message || "Ошибка создания канала");
|
const errorMsg =
|
||||||
|
typeof err?.detail === "string"
|
||||||
|
? err.detail
|
||||||
|
: err?.error?.message || "Ошибка создания канала";
|
||||||
|
alert(errorMsg);
|
||||||
} finally {
|
} finally {
|
||||||
setIsCreating(false);
|
setIsCreating(false);
|
||||||
}
|
}
|
||||||
@@ -317,7 +343,7 @@ export default function ExternalChannelsPage() {
|
|||||||
<CardDescription className="truncate mt-1">
|
<CardDescription className="truncate mt-1">
|
||||||
{channel.username ? (
|
{channel.username ? (
|
||||||
<a
|
<a
|
||||||
href={channel.link}
|
href={`https://t.me/${channel.username}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="hover:underline"
|
className="hover:underline"
|
||||||
@@ -325,14 +351,9 @@ export default function ExternalChannelsPage() {
|
|||||||
{formatUsername(channel.username)}
|
{formatUsername(channel.username)}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<a
|
<span className="text-xs">
|
||||||
href={channel.link}
|
ID: {channel.telegram_id}
|
||||||
target="_blank"
|
</span>
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:underline text-xs"
|
|
||||||
>
|
|
||||||
{channel.link}
|
|
||||||
</a>
|
|
||||||
)}
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,8 +366,8 @@ export default function ExternalChannelsPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-2 text-center">
|
<div className="flex items-center gap-2">
|
||||||
<div className="rounded-lg border bg-muted/50 p-2">
|
<div className="flex-1 rounded-lg border bg-muted/50 p-2">
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
<Users className="h-3 w-3 text-muted-foreground" />
|
<Users className="h-3 w-3 text-muted-foreground" />
|
||||||
<div className="text-sm font-medium">
|
<div className="text-sm font-medium">
|
||||||
@@ -355,44 +376,29 @@ export default function ExternalChannelsPage() {
|
|||||||
: "—"}
|
: "—"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground text-center">
|
||||||
Подписчики
|
Подписчики
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="text-sm font-medium">
|
||||||
{formatNumber(channel.total_purchases)}
|
ID: {channel.telegram_id}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
Закупов
|
Telegram ID
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
<Button
|
<Button
|
||||||
asChild
|
variant="destructive"
|
||||||
variant="default"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex-1"
|
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)}
|
onClick={() => handleDelete(channel.id, channel.title)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Удалить
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -19,15 +19,6 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
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 { purchasesApi } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
formatNumber,
|
formatNumber,
|
||||||
@@ -54,7 +45,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { PurchaseDetail } from "@/lib/types/api";
|
import type { Placement } from "@/lib/types/api";
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -63,7 +54,7 @@ interface PageProps {
|
|||||||
export default function PurchaseDetailPage({ params }: PageProps) {
|
export default function PurchaseDetailPage({ params }: PageProps) {
|
||||||
const resolvedParams = use(params);
|
const resolvedParams = use(params);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [purchase, setPurchase] = useState<PurchaseDetail | null>(null);
|
const [purchase, setPurchase] = useState<Placement | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@@ -89,9 +80,12 @@ export default function PurchaseDetailPage({ params }: PageProps) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await purchasesApi.update(purchase.id, {
|
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) {
|
} catch (err: any) {
|
||||||
alert(err?.error?.message || "Ошибка обновления закупа");
|
alert(err?.error?.message || "Ошибка обновления закупа");
|
||||||
}
|
}
|
||||||
@@ -157,10 +151,8 @@ export default function PurchaseDetailPage({ params }: PageProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageText = purchase.creative.text.replace(
|
// Remove message text generation as we don't have creative.text in Placement
|
||||||
"{link}",
|
const messageText = `Текст сообщения с ссылкой: ${purchase.invite_link}`;
|
||||||
purchase.invite_link
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -177,19 +169,14 @@ export default function PurchaseDetailPage({ params }: PageProps) {
|
|||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<ShoppingCart className="h-6 w-6" />
|
<ShoppingCart className="h-6 w-6" />
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
{purchase.external_channel.title}
|
{purchase.external_channel_title}
|
||||||
</h1>
|
</h1>
|
||||||
{purchase.is_archived ? (
|
{purchase.status === "archived" ? (
|
||||||
<Badge variant="secondary">Архив</Badge>
|
<Badge variant="secondary">Архив</Badge>
|
||||||
) : purchase.actual_date ? (
|
) : (
|
||||||
<Badge variant="default">
|
<Badge variant="default">
|
||||||
<CheckCircle className="h-3 w-3 mr-1" />
|
<CheckCircle className="h-3 w-3 mr-1" />
|
||||||
Завершено
|
Активно
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline">
|
|
||||||
<Clock className="h-3 w-3 mr-1" />
|
|
||||||
Запланировано
|
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -197,33 +184,34 @@ export default function PurchaseDetailPage({ params }: PageProps) {
|
|||||||
<span>
|
<span>
|
||||||
Целевой канал:{" "}
|
Целевой канал:{" "}
|
||||||
<Link
|
<Link
|
||||||
href={`/channels/${purchase.target_channel.id}`}
|
href={`/channels`}
|
||||||
className="hover:underline font-medium"
|
className="hover:underline font-medium"
|
||||||
>
|
>
|
||||||
{purchase.target_channel.title}
|
{purchase.target_channel_title}
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>
|
<span>
|
||||||
Креатив:{" "}
|
Креатив:{" "}
|
||||||
<Link
|
<Link
|
||||||
href={`/creatives/${purchase.creative.id}`}
|
href={`/creatives`}
|
||||||
className="hover:underline font-medium"
|
className="hover:underline font-medium"
|
||||||
>
|
>
|
||||||
{purchase.creative.name}
|
{purchase.creative_name}
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>
|
<span>
|
||||||
Дата размещения:{" "}
|
Дата размещения: {formatDate(purchase.placement_date)}
|
||||||
{formatDate(purchase.actual_date || purchase.scheduled_date)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" onClick={handleArchive}>
|
<Button variant="outline" onClick={handleArchive}>
|
||||||
<Archive className="mr-2 h-4 w-4" />
|
<Archive className="mr-2 h-4 w-4" />
|
||||||
{purchase.is_archived ? "Разархивировать" : "Архивировать"}
|
{purchase.status === "archived"
|
||||||
|
? "Разархивировать"
|
||||||
|
: "Архивировать"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={handleDelete}>
|
<Button variant="destructive" onClick={handleDelete}>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
@@ -272,27 +260,45 @@ export default function PurchaseDetailPage({ params }: PageProps) {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<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" />
|
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-lg font-medium">
|
||||||
{purchase.cpf ? `₽${formatMetric(purchase.cpf)}` : "—"}
|
{purchase.views_availability === "available"
|
||||||
|
? "Доступны"
|
||||||
|
: purchase.views_availability === "manual"
|
||||||
|
? "Вручную"
|
||||||
|
: purchase.views_availability === "unavailable"
|
||||||
|
? "Недоступны"
|
||||||
|
: "Неизвестно"}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{purchase.last_views_fetch_at
|
||||||
|
? `Обновлено: ${formatDate(purchase.last_views_fetch_at)}`
|
||||||
|
: "Еще не обновлялись"}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<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" />
|
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-lg font-medium">
|
||||||
{purchase.conversion_rate
|
{purchase.invite_link_type === "approval"
|
||||||
? formatPercent(purchase.conversion_rate)
|
? "С одобрением"
|
||||||
: "—"}
|
: "Публичная"}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{purchase.invite_link_type === "approval"
|
||||||
|
? "Отслеживание подписчиков"
|
||||||
|
: "Без отслеживания"}
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -318,18 +324,18 @@ export default function PurchaseDetailPage({ params }: PageProps) {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{purchase.post_link && (
|
{purchase.ad_post_url && (
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<Label className="text-sm text-muted-foreground">
|
<Label className="text-sm text-muted-foreground">
|
||||||
Ссылка на рекламный пост:
|
Ссылка на рекламный пост:
|
||||||
</Label>
|
</Label>
|
||||||
<a
|
<a
|
||||||
href={purchase.post_link}
|
href={purchase.ad_post_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1 text-sm hover:underline mt-1"
|
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" />
|
<ExternalLinkIcon className="h-3 w-3" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -363,127 +369,21 @@ export default function PurchaseDetailPage({ params }: PageProps) {
|
|||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Подписки</CardTitle>
|
<CardTitle>Заметка</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Пользователи, перешедшие по ссылке
|
Детальная информация о размещении доступна через раздел "Подписки"
|
||||||
|
в главном меню
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{purchase.subscriptions.length === 0 ? (
|
<p className="text-sm text-muted-foreground">
|
||||||
<p className="text-center text-muted-foreground py-8">
|
Для просмотра списка подписчиков и истории просмотров используйте
|
||||||
Подписок пока нет
|
соответствующие разделы в главном меню.
|
||||||
</p>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,11 +60,11 @@ export default function CreatePurchasePage() {
|
|||||||
target_channel_id: "",
|
target_channel_id: "",
|
||||||
external_channel_id: "",
|
external_channel_id: "",
|
||||||
creative_id: "",
|
creative_id: "",
|
||||||
scheduled_date: "",
|
placement_date: "",
|
||||||
cost: "",
|
cost: "",
|
||||||
comment: "",
|
comment: "",
|
||||||
post_link: "",
|
post_link: "",
|
||||||
invite_link_type: "public" as "public" | "private",
|
invite_link_type: "public" as "public" | "approval",
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -73,15 +73,24 @@ export default function CreatePurchasePage() {
|
|||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
const [channelsRes, externalChannelsRes, creativesRes] =
|
const channelsRes = await channelsApi.list();
|
||||||
await Promise.all([
|
setChannels(channelsRes.target_channels.filter((c) => c.is_active));
|
||||||
channelsApi.list({ is_active: true }),
|
|
||||||
externalChannelsApi.list(),
|
// Загружаем внешние каналы для первого доступного 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(),
|
creativesApi.list(),
|
||||||
]);
|
]);
|
||||||
setChannels(channelsRes.data);
|
setExternalChannels(externalChannelsRes.external_channels);
|
||||||
setExternalChannels(externalChannelsRes.data);
|
setCreatives(
|
||||||
setCreatives(creativesRes.data.filter((c) => !c.is_archived));
|
creativesRes.creatives.filter((c) => c.status === "active")
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setCreatives([]);
|
||||||
|
setExternalChannels([]);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error loading data:", err);
|
console.error("Error loading data:", err);
|
||||||
}
|
}
|
||||||
@@ -116,7 +125,7 @@ export default function CreatePurchasePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.scheduled_date) {
|
if (!formData.placement_date) {
|
||||||
setError("Укажите дату размещения");
|
setError("Укажите дату размещения");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -132,10 +141,10 @@ export default function CreatePurchasePage() {
|
|||||||
target_channel_id: formData.target_channel_id,
|
target_channel_id: formData.target_channel_id,
|
||||||
external_channel_id: formData.external_channel_id,
|
external_channel_id: formData.external_channel_id,
|
||||||
creative_id: formData.creative_id,
|
creative_id: formData.creative_id,
|
||||||
scheduled_date: formData.scheduled_date,
|
placement_date: formData.placement_date,
|
||||||
cost: parseFloat(formData.cost),
|
cost: parseFloat(formData.cost),
|
||||||
comment: formData.comment || undefined,
|
comment: formData.comment || undefined,
|
||||||
post_link: formData.post_link || undefined,
|
ad_post_url: formData.post_link || undefined,
|
||||||
invite_link_type: formData.invite_link_type,
|
invite_link_type: formData.invite_link_type,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -247,7 +256,7 @@ export default function CreatePurchasePage() {
|
|||||||
target_channel_id: "",
|
target_channel_id: "",
|
||||||
external_channel_id: "",
|
external_channel_id: "",
|
||||||
creative_id: "",
|
creative_id: "",
|
||||||
scheduled_date: "",
|
placement_date: "",
|
||||||
cost: "",
|
cost: "",
|
||||||
comment: "",
|
comment: "",
|
||||||
post_link: "",
|
post_link: "",
|
||||||
@@ -394,15 +403,15 @@ export default function CreatePurchasePage() {
|
|||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="scheduled_date">Дата размещения *</Label>
|
<Label htmlFor="placement_date">Дата размещения *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="scheduled_date"
|
id="placement_date"
|
||||||
type="date"
|
type="datetime-local"
|
||||||
value={formData.scheduled_date}
|
value={formData.placement_date}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
scheduled_date: e.target.value,
|
placement_date: e.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -443,7 +452,7 @@ export default function CreatePurchasePage() {
|
|||||||
<Label>Тип пригласительной ссылки *</Label>
|
<Label>Тип пригласительной ссылки *</Label>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={formData.invite_link_type}
|
value={formData.invite_link_type}
|
||||||
onValueChange={(value: "public" | "private") =>
|
onValueChange={(value: "public" | "approval") =>
|
||||||
setFormData({ ...formData, invite_link_type: value })
|
setFormData({ ...formData, invite_link_type: value })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -454,8 +463,8 @@ export default function CreatePurchasePage() {
|
|||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="private" id="private" />
|
<RadioGroupItem value="approval" id="approval" />
|
||||||
<Label htmlFor="private" className="font-normal">
|
<Label htmlFor="approval" className="font-normal">
|
||||||
С одобрением (запрос на вступление через бота)
|
С одобрением (запрос на вступление через бота)
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -73,10 +73,10 @@ export default function PurchasesPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
const [purchasesRes, channelsRes] = await Promise.all([
|
const [purchasesRes, channelsRes] = await Promise.all([
|
||||||
purchasesApi.list(),
|
purchasesApi.list(),
|
||||||
channelsApi.list({}),
|
channelsApi.list(),
|
||||||
]);
|
]);
|
||||||
setPurchases(purchasesRes.data);
|
setPurchases(purchasesRes.placements);
|
||||||
setChannels(channelsRes.data);
|
setChannels(channelsRes.target_channels);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err?.error?.message || "Ошибка загрузки данных");
|
setError(err?.error?.message || "Ошибка загрузки данных");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -87,26 +87,24 @@ export default function PurchasesPage() {
|
|||||||
const filteredPurchases = purchases.filter((purchase) => {
|
const filteredPurchases = purchases.filter((purchase) => {
|
||||||
// Поиск
|
// Поиск
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
purchase.external_channel.title
|
purchase.external_channel_title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(searchQuery.toLowerCase()) ||
|
.includes(searchQuery.toLowerCase()) ||
|
||||||
purchase.target_channel.title
|
purchase.target_channel_title
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(searchQuery.toLowerCase()) ||
|
.includes(searchQuery.toLowerCase()) ||
|
||||||
purchase.creative.name.toLowerCase().includes(searchQuery.toLowerCase());
|
purchase.creative_name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
|
||||||
// Фильтр по каналу
|
// Фильтр по каналу
|
||||||
const matchesChannel =
|
const matchesChannel =
|
||||||
filterChannel === "all" || purchase.target_channel.id === filterChannel;
|
filterChannel === "all" || purchase.target_channel_id === filterChannel;
|
||||||
|
|
||||||
// Фильтр по статусу
|
// Фильтр по статусу
|
||||||
let matchesStatus = true;
|
let matchesStatus = true;
|
||||||
if (filterStatus === "completed") {
|
if (filterStatus === "archived") {
|
||||||
matchesStatus = !!purchase.actual_date;
|
matchesStatus = purchase.status === "archived";
|
||||||
} else if (filterStatus === "scheduled") {
|
} else if (filterStatus === "active") {
|
||||||
matchesStatus = !purchase.actual_date;
|
matchesStatus = purchase.status === "active";
|
||||||
} else if (filterStatus === "archived") {
|
|
||||||
matchesStatus = purchase.is_archived;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return matchesSearch && matchesChannel && matchesStatus;
|
return matchesSearch && matchesChannel && matchesStatus;
|
||||||
@@ -115,17 +113,13 @@ export default function PurchasesPage() {
|
|||||||
// Статистика
|
// Статистика
|
||||||
const stats = {
|
const stats = {
|
||||||
total: purchases.length,
|
total: purchases.length,
|
||||||
completed: purchases.filter((p) => p.actual_date).length,
|
active: purchases.filter((p) => p.status === "active").length,
|
||||||
scheduled: purchases.filter((p) => !p.actual_date).length,
|
archived: purchases.filter((p) => p.status === "archived").length,
|
||||||
totalCost: purchases.reduce((sum, p) => sum + (p.cost || 0), 0),
|
totalCost: purchases.reduce((sum, p) => sum + (p.cost || 0), 0),
|
||||||
totalSubscriptions: purchases.reduce(
|
totalSubscriptions: purchases.reduce(
|
||||||
(sum, p) => sum + p.subscriptions_count,
|
(sum, p) => sum + p.subscriptions_count,
|
||||||
0
|
0
|
||||||
),
|
),
|
||||||
avgCpf:
|
|
||||||
purchases.length > 0
|
|
||||||
? purchases.reduce((sum, p) => sum + (p.cpf || 0), 0) / purchases.length
|
|
||||||
: 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -169,7 +163,7 @@ export default function PurchasesPage() {
|
|||||||
{formatNumber(stats.total)}
|
{formatNumber(stats.total)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{stats.completed} завершено, {stats.scheduled} запланировано
|
{stats.active} активных, {stats.archived} в архиве
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -203,20 +197,6 @@ export default function PurchasesPage() {
|
|||||||
<p className="text-xs text-muted-foreground mt-1">Всего</p>
|
<p className="text-xs text-muted-foreground mt-1">Всего</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -308,9 +288,8 @@ export default function PurchasesPage() {
|
|||||||
<TableHead>Креатив</TableHead>
|
<TableHead>Креатив</TableHead>
|
||||||
<TableHead>Дата</TableHead>
|
<TableHead>Дата</TableHead>
|
||||||
<TableHead className="text-right">Стоимость</TableHead>
|
<TableHead className="text-right">Стоимость</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></TableHead>
|
<TableHead></TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -318,34 +297,27 @@ export default function PurchasesPage() {
|
|||||||
{filteredPurchases.map((purchase) => (
|
{filteredPurchases.map((purchase) => (
|
||||||
<TableRow key={purchase.id}>
|
<TableRow key={purchase.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{purchase.is_archived ? (
|
{purchase.status === "archived" ? (
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
<Archive className="h-3 w-3 mr-1" />
|
<Archive className="h-3 w-3 mr-1" />
|
||||||
Архив
|
Архив
|
||||||
</Badge>
|
</Badge>
|
||||||
) : purchase.actual_date ? (
|
) : (
|
||||||
<Badge variant="default">
|
<Badge variant="default">
|
||||||
<CheckCircle className="h-3 w-3 mr-1" />
|
<CheckCircle className="h-3 w-3 mr-1" />
|
||||||
Завершено
|
Активно
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline">
|
|
||||||
<Clock className="h-3 w-3 mr-1" />
|
|
||||||
Запланировано
|
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
{purchase.target_channel.title}
|
{purchase.target_channel_title}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{purchase.external_channel.title}</TableCell>
|
<TableCell>{purchase.external_channel_title}</TableCell>
|
||||||
<TableCell className="max-w-[200px] truncate">
|
<TableCell className="max-w-[200px] truncate">
|
||||||
{purchase.creative.name}
|
{purchase.creative_name}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{formatDate(
|
{formatDate(purchase.placement_date)}
|
||||||
purchase.actual_date || purchase.scheduled_date
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{formatCurrency(purchase.cost)}
|
{formatCurrency(purchase.cost)}
|
||||||
@@ -354,11 +326,8 @@ export default function PurchasesPage() {
|
|||||||
{formatNumber(purchase.subscriptions_count)}
|
{formatNumber(purchase.subscriptions_count)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{purchase.cpf ? `₽${formatMetric(purchase.cpf)}` : "—"}
|
{purchase.views_count
|
||||||
</TableCell>
|
? formatNumber(purchase.views_count)
|
||||||
<TableCell className="text-right">
|
|
||||||
{purchase.conversion_rate
|
|
||||||
? formatPercent(purchase.conversion_rate)
|
|
||||||
: "—"}
|
: "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import React, {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { authApi } from "@/lib/api";
|
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";
|
import type { User } from "@/lib/types/api";
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
@@ -85,13 +85,28 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const response = await authApi.complete(token);
|
// Exchange token for JWT
|
||||||
setUser(response.user);
|
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
|
// Redirect to home
|
||||||
router.push("/");
|
router.push("/");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const errorMessage = err?.error?.message || "Ошибка авторизации";
|
const errorMessage = err?.detail || err?.error?.message || "Ошибка авторизации";
|
||||||
setError(errorMessage);
|
setError(errorMessage);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -143,13 +143,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
{user && (
|
{user && (
|
||||||
<NavUser
|
<NavUser
|
||||||
user={{
|
user={{
|
||||||
name: `${user.first_name}${
|
name: user.username || `User ${user.telegram_id}`,
|
||||||
user.last_name ? " " + user.last_name : ""
|
|
||||||
}`,
|
|
||||||
email: user.username
|
email: user.username
|
||||||
? `@${user.username}`
|
? `@${user.username}`
|
||||||
: `ID: ${user.telegram_id}`,
|
: `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 auth with one-time token from bot
|
||||||
*/
|
*/
|
||||||
complete: async (token: string): Promise<AuthCompleteResponse> => {
|
complete: async (token: string): Promise<{ access_token: string }> => {
|
||||||
const data: AuthCompleteRequest = { token };
|
// GET request with token as query parameter
|
||||||
const response = await api.post<AuthCompleteResponse>(
|
const response = await api.get<{ access_token: string }>(
|
||||||
"/auth/complete",
|
`/auth/complete?token=${token}`,
|
||||||
data,
|
|
||||||
{
|
{
|
||||||
requireAuth: false,
|
requireAuth: false,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save tokens to storage
|
// Save access token to storage
|
||||||
saveAuthTokens(response.access_token, response.refresh_token);
|
|
||||||
|
|
||||||
// Save user to storage
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(response.user));
|
localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, response.access_token);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@@ -5,33 +5,20 @@
|
|||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
import type {
|
import type {
|
||||||
TargetChannel,
|
TargetChannel,
|
||||||
TargetChannelDetail,
|
TargetChannelsListResponse,
|
||||||
TargetChannelQueryParams,
|
|
||||||
TargetChannelUpdateRequest,
|
|
||||||
ListResponse,
|
|
||||||
SuccessResponse,
|
SuccessResponse,
|
||||||
} from "@/lib/types/api";
|
} from "@/lib/types/api";
|
||||||
|
|
||||||
export const channelsApi = {
|
export const channelsApi = {
|
||||||
/**
|
/**
|
||||||
* Get list of target channels
|
* Get list of target channels
|
||||||
|
* GET /api/v1/target_channels
|
||||||
*/
|
*/
|
||||||
list: (params?: TargetChannelQueryParams) =>
|
list: () => api.get<TargetChannelsListResponse>("/target_channels"),
|
||||||
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),
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete (disconnect) target channel
|
* 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") {
|
if (endpoint === "/auth/init") {
|
||||||
return mockApiHandlers.authInit();
|
return mockApiHandlers.authInit();
|
||||||
}
|
}
|
||||||
if (endpoint === "/auth/complete") {
|
if (endpoint.startsWith("/auth/complete")) {
|
||||||
return mockApiHandlers.authComplete(body);
|
// 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") {
|
if (endpoint === "/auth/refresh") {
|
||||||
return mockApiHandlers.authRefresh(body);
|
return mockApiHandlers.authRefresh(body);
|
||||||
@@ -100,53 +106,51 @@ const routeMockRequest = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Target Channels
|
// Target Channels
|
||||||
if (endpoint === "/target-channels" && method === "GET") {
|
if (endpoint === "/target_channels" && method === "GET") {
|
||||||
return mockApiHandlers.getTargetChannels(params);
|
return mockApiHandlers.getTargetChannels();
|
||||||
}
|
}
|
||||||
if (endpoint.startsWith("/target-channels/") && method === "GET") {
|
if (endpoint.startsWith("/target_channels/") && method === "DELETE") {
|
||||||
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") {
|
|
||||||
const id = endpoint.split("/")[2];
|
const id = endpoint.split("/")[2];
|
||||||
return mockApiHandlers.deleteTargetChannel(id);
|
return mockApiHandlers.deleteTargetChannel(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// External Channels
|
// External Channels
|
||||||
if (endpoint === "/external-channels" && method === "GET") {
|
if (endpoint.startsWith("/external_channels/target/") && method === "GET") {
|
||||||
return mockApiHandlers.getExternalChannels(params);
|
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);
|
return mockApiHandlers.createExternalChannel(body);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
endpoint.startsWith("/external-channels/") &&
|
endpoint.startsWith("/external_channels/") &&
|
||||||
!endpoint.includes("/import") &&
|
!endpoint.includes("/target/") &&
|
||||||
|
!endpoint.includes("/links") &&
|
||||||
method === "GET"
|
method === "GET"
|
||||||
) {
|
) {
|
||||||
const id = endpoint.split("/")[2];
|
const id = endpoint.split("/")[2];
|
||||||
return mockApiHandlers.getExternalChannel(id);
|
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];
|
const id = endpoint.split("/")[2];
|
||||||
return mockApiHandlers.updateExternalChannel(id, body);
|
return mockApiHandlers.updateExternalChannel(id, body);
|
||||||
}
|
}
|
||||||
if (endpoint.startsWith("/external-channels/") && method === "DELETE") {
|
if (endpoint.startsWith("/external_channels/") && method === "DELETE") {
|
||||||
const id = endpoint.split("/")[2];
|
const id = endpoint.split("/")[2];
|
||||||
return mockApiHandlers.deleteExternalChannel(id);
|
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
|
// Creatives
|
||||||
if (endpoint === "/creatives" && method === "GET") {
|
if (endpoint === "/creatives" && method === "GET") {
|
||||||
@@ -168,28 +172,36 @@ const routeMockRequest = async (
|
|||||||
return mockApiHandlers.deleteCreative(id);
|
return mockApiHandlers.deleteCreative(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Purchases
|
// Placements
|
||||||
if (endpoint === "/purchases" && method === "GET") {
|
if (endpoint === "/placements" && method === "GET") {
|
||||||
return mockApiHandlers.getPurchases(params);
|
return mockApiHandlers.getPlacements(params);
|
||||||
}
|
}
|
||||||
if (endpoint === "/purchases" && method === "POST") {
|
if (endpoint === "/placements" && method === "POST") {
|
||||||
return mockApiHandlers.createPurchase(body);
|
return mockApiHandlers.createPlacement(body);
|
||||||
}
|
}
|
||||||
if (endpoint.match(/^\/purchases\/[^\/]+$/) && method === "GET") {
|
if (endpoint.match(/^\/placements\/[^\/]+$/) && method === "GET") {
|
||||||
const id = endpoint.split("/")[2];
|
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];
|
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];
|
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];
|
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
|
// Analytics
|
||||||
@@ -264,6 +276,27 @@ export const apiRequest = async <T = any>(
|
|||||||
|
|
||||||
// Handle errors
|
// Handle errors
|
||||||
if (!response.ok) {
|
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;
|
const error: ApiError = data;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -314,13 +347,7 @@ export const api = {
|
|||||||
formData: FormData,
|
formData: FormData,
|
||||||
options?: RequestOptions
|
options?: RequestOptions
|
||||||
): Promise<T> => {
|
): Promise<T> => {
|
||||||
if (USE_MOCKS) {
|
// File import is now done on client side, no need for mock handling
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `${API_URL}${endpoint}`;
|
const url = `${API_URL}${endpoint}`;
|
||||||
const token = getAuthToken();
|
const token = getAuthToken();
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,10 @@
|
|||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
import type {
|
import type {
|
||||||
Creative,
|
Creative,
|
||||||
CreativeDetail,
|
|
||||||
CreativeQueryParams,
|
CreativeQueryParams,
|
||||||
CreativeCreateRequest,
|
CreativeCreateRequest,
|
||||||
CreativeUpdateRequest,
|
CreativeUpdateRequest,
|
||||||
ListResponse,
|
CreativesListResponse,
|
||||||
SuccessResponse,
|
SuccessResponse,
|
||||||
} from "@/lib/types/api";
|
} from "@/lib/types/api";
|
||||||
|
|
||||||
@@ -18,12 +17,12 @@ export const creativesApi = {
|
|||||||
* Get list of creatives
|
* Get list of creatives
|
||||||
*/
|
*/
|
||||||
list: (params?: CreativeQueryParams) =>
|
list: (params?: CreativeQueryParams) =>
|
||||||
api.get<ListResponse<Creative>>("/creatives", { params }),
|
api.get<CreativesListResponse>("/creatives", { params }),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get creative details
|
* Get creative details
|
||||||
*/
|
*/
|
||||||
get: (id: string) => api.get<CreativeDetail>(`/creatives/${id}`),
|
get: (id: string) => api.get<Creative>(`/creatives/${id}`),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create new creative
|
* Create new creative
|
||||||
|
|||||||
@@ -5,57 +5,48 @@
|
|||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
import type {
|
import type {
|
||||||
ExternalChannel,
|
ExternalChannel,
|
||||||
ExternalChannelDetail,
|
ExternalChannelsListResponse,
|
||||||
ExternalChannelQueryParams,
|
|
||||||
ExternalChannelCreateRequest,
|
ExternalChannelCreateRequest,
|
||||||
ExternalChannelUpdateRequest,
|
ExternalChannelUpdateRequest,
|
||||||
ExternalChannelImportResponse,
|
ExternalChannelLinksUpdateRequest,
|
||||||
ListResponse,
|
|
||||||
SuccessResponse,
|
SuccessResponse,
|
||||||
} from "@/lib/types/api";
|
} from "@/lib/types/api";
|
||||||
|
|
||||||
export const externalChannelsApi = {
|
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) =>
|
list: (targetChannelId: string) =>
|
||||||
api.get<ListResponse<ExternalChannel>>("/external-channels", { params }),
|
api.get<ExternalChannelsListResponse>(
|
||||||
|
`/external_channels/target/${targetChannelId}`
|
||||||
/**
|
),
|
||||||
* Get external channel details
|
|
||||||
*/
|
|
||||||
get: (id: string) =>
|
|
||||||
api.get<ExternalChannelDetail>(`/external-channels/${id}`),
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create new external channel
|
* Create new external channel
|
||||||
|
* POST /api/v1/external_channels
|
||||||
*/
|
*/
|
||||||
create: (data: ExternalChannelCreateRequest) =>
|
create: (data: ExternalChannelCreateRequest) =>
|
||||||
api.post<ExternalChannel>("/external-channels", data),
|
api.post<ExternalChannel>("/external_channels", data),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update external channel
|
* Update external channel
|
||||||
|
* PATCH /api/v1/external_channels/{channel_id}
|
||||||
*/
|
*/
|
||||||
update: (id: string, data: ExternalChannelUpdateRequest) =>
|
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 external channel
|
||||||
|
* DELETE /api/v1/external_channels/{channel_id}
|
||||||
*/
|
*/
|
||||||
delete: (id: string) =>
|
delete: (id: string) =>
|
||||||
api.delete<SuccessResponse>(`/external-channels/${id}`),
|
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
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ export * from "./auth";
|
|||||||
export * from "./channels";
|
export * from "./channels";
|
||||||
export * from "./external-channels";
|
export * from "./external-channels";
|
||||||
export * from "./creatives";
|
export * from "./creatives";
|
||||||
export * from "./purchases";
|
export * from "./placements";
|
||||||
export * from "./analytics";
|
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
|
// Mock Target Channels Data
|
||||||
@@ -6,74 +6,24 @@ import { TargetChannel, TargetChannelDetail } from "@/lib/types/api";
|
|||||||
|
|
||||||
export const mockTargetChannels: TargetChannel[] = [
|
export const mockTargetChannels: TargetChannel[] = [
|
||||||
{
|
{
|
||||||
id: "tc1",
|
id: "550e8400-e29b-41d4-a716-446655440010",
|
||||||
telegram_id: -1001234567890,
|
telegram_id: -1001234567890,
|
||||||
title: "Мой Главный Канал",
|
title: "Мой Главный Канал",
|
||||||
username: "my_main_channel",
|
username: "my_main_channel",
|
||||||
description: "Основной канал для продвижения продукта",
|
|
||||||
is_active: true,
|
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,
|
telegram_id: -1009876543210,
|
||||||
title: "Тестовый Канал",
|
title: "Тестовый Канал",
|
||||||
username: "test_channel_promo",
|
username: "test_channel_promo",
|
||||||
description: "Канал для тестирования рекламных креативов",
|
|
||||||
is_active: true,
|
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,
|
telegram_id: -1005555555555,
|
||||||
title: "Новости и Обновления",
|
title: "Новости и Обновления",
|
||||||
username: null,
|
username: null,
|
||||||
description: "Приватный канал для новостей",
|
|
||||||
is_active: false,
|
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";
|
import { mockTargetChannels } from "./channels";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -11,61 +11,39 @@ export const mockCreatives: Creative[] = [
|
|||||||
name: 'Креатив "Присоединяйся"',
|
name: 'Креатив "Присоединяйся"',
|
||||||
text: "🔥 Присоединяйся к нашему сообществу!\n\nУзнавай первым о новых возможностях и фишках.\n\n👉 {link}",
|
text: "🔥 Присоединяйся к нашему сообществу!\n\nУзнавай первым о новых возможностях и фишках.\n\n👉 {link}",
|
||||||
target_channel_id: "tc1",
|
target_channel_id: "tc1",
|
||||||
is_archived: false,
|
target_channel_title: mockTargetChannels[0].title,
|
||||||
created_at: "2024-01-15T10:00:00.000Z",
|
created_at: "2024-01-15T10:00:00.000Z",
|
||||||
updated_at: "2024-01-15T10:00:00.000Z",
|
status: "active",
|
||||||
target_channel: mockTargetChannels[0],
|
placements_count: 10,
|
||||||
total_purchases: 10,
|
|
||||||
total_subscriptions: 850,
|
|
||||||
avg_cpf: 42.3,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "cr2",
|
id: "cr2",
|
||||||
name: 'Креатив "Эксклюзив"',
|
name: 'Креатив "Эксклюзив"',
|
||||||
text: "⭐ Эксклюзивный контент только для подписчиков!\n\nНе упусти возможность быть в курсе всех трендов.\n\nПереходи: {link}",
|
text: "⭐ Эксклюзивный контент только для подписчиков!\n\nНе упусти возможность быть в курсе всех трендов.\n\nПереходи: {link}",
|
||||||
target_channel_id: "tc1",
|
target_channel_id: "tc1",
|
||||||
is_archived: false,
|
target_channel_title: mockTargetChannels[0].title,
|
||||||
created_at: "2024-01-20T12:00:00.000Z",
|
created_at: "2024-01-20T12:00:00.000Z",
|
||||||
updated_at: "2024-01-20T12:00:00.000Z",
|
status: "active",
|
||||||
target_channel: mockTargetChannels[0],
|
placements_count: 5,
|
||||||
total_purchases: 5,
|
|
||||||
total_subscriptions: 400,
|
|
||||||
avg_cpf: 48.5,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "cr3",
|
id: "cr3",
|
||||||
name: 'Креатив "Обучение"',
|
name: 'Креатив "Обучение"',
|
||||||
text: "📚 Хочешь научиться зарабатывать больше?\n\nПодписывайся на канал с проверенными методиками!\n\n{link}",
|
text: "📚 Хочешь научиться зарабатывать больше?\n\nПодписывайся на канал с проверенными методиками!\n\n{link}",
|
||||||
target_channel_id: "tc2",
|
target_channel_id: "tc2",
|
||||||
is_archived: false,
|
target_channel_title: mockTargetChannels[1].title,
|
||||||
created_at: "2024-02-05T09:00:00.000Z",
|
created_at: "2024-02-05T09:00:00.000Z",
|
||||||
updated_at: "2024-02-05T09:00:00.000Z",
|
status: "active",
|
||||||
target_channel: mockTargetChannels[1],
|
placements_count: 8,
|
||||||
total_purchases: 8,
|
|
||||||
total_subscriptions: 450,
|
|
||||||
avg_cpf: 52.3,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "cr4",
|
id: "cr4",
|
||||||
name: "Старый креатив (архив)",
|
name: "Старый креатив (архив)",
|
||||||
text: "Устаревший текст с предложением. {link}",
|
text: "Устаревший текст с предложением. {link}",
|
||||||
target_channel_id: "tc1",
|
target_channel_id: "tc1",
|
||||||
is_archived: true,
|
target_channel_title: mockTargetChannels[0].title,
|
||||||
created_at: "2023-12-01T10:00:00.000Z",
|
created_at: "2023-12-01T10:00:00.000Z",
|
||||||
updated_at: "2024-01-10T15:00:00.000Z",
|
status: "archived",
|
||||||
target_channel: mockTargetChannels[0],
|
placements_count: 2,
|
||||||
total_purchases: 2,
|
|
||||||
total_subscriptions: 80,
|
|
||||||
avg_cpf: 75.0,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
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
|
// Mock External Channels Data
|
||||||
@@ -6,90 +6,43 @@ import { ExternalChannel, ExternalChannelDetail } from "@/lib/types/api";
|
|||||||
|
|
||||||
export const mockExternalChannels: ExternalChannel[] = [
|
export const mockExternalChannels: ExternalChannel[] = [
|
||||||
{
|
{
|
||||||
id: "ec1",
|
id: "550e8400-e29b-41d4-a716-446655440020",
|
||||||
telegram_id: -1001111111111,
|
telegram_id: -1001111111111,
|
||||||
title: "Канал о Маркетинге",
|
title: "Канал о Маркетинге",
|
||||||
username: "marketing_pro",
|
username: "marketing_pro",
|
||||||
link: "https://t.me/marketing_pro",
|
|
||||||
subscribers_count: 50000,
|
subscribers_count: 50000,
|
||||||
description: "Лучшие практики маркетинга",
|
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,
|
telegram_id: -1002222222222,
|
||||||
title: "Бизнес и Стартапы",
|
title: "Бизнес и Стартапы",
|
||||||
username: "business_startups",
|
username: "business_startups",
|
||||||
link: "https://t.me/business_startups",
|
|
||||||
subscribers_count: 120000,
|
subscribers_count: 120000,
|
||||||
description: "Новости мира бизнеса",
|
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,
|
telegram_id: -1003333333333,
|
||||||
title: "IT и Технологии",
|
title: "IT и Технологии",
|
||||||
username: "it_tech_news",
|
username: "it_tech_news",
|
||||||
link: "https://t.me/it_tech_news",
|
|
||||||
subscribers_count: 85000,
|
subscribers_count: 85000,
|
||||||
description: "Все о новых технологиях",
|
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",
|
id: "550e8400-e29b-41d4-a716-446655440023",
|
||||||
telegram_id: null,
|
telegram_id: -1004444444444,
|
||||||
title: "Крипто Инвестиции",
|
title: "Крипто Инвестиции",
|
||||||
username: "crypto_invest",
|
username: "crypto_invest",
|
||||||
link: "https://t.me/crypto_invest",
|
|
||||||
subscribers_count: 200000,
|
subscribers_count: 200000,
|
||||||
description: null,
|
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,
|
telegram_id: -1005555555555,
|
||||||
title: "Продажи и Переговоры",
|
title: "Продажи и Переговоры",
|
||||||
username: "sales_expert",
|
username: "sales_expert",
|
||||||
link: "https://t.me/sales_expert",
|
|
||||||
subscribers_count: 35000,
|
subscribers_count: 35000,
|
||||||
description: "Экспертиза в продажах",
|
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[] = [
|
export const mockUsers: User[] = [
|
||||||
{
|
{
|
||||||
id: "1",
|
id: "550e8400-e29b-41d4-a716-446655440001",
|
||||||
telegram_id: 123456789,
|
telegram_id: 123456789,
|
||||||
username: "ivan_test",
|
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,
|
telegram_id: 987654321,
|
||||||
username: "maria_test",
|
username: "maria_test",
|
||||||
first_name: "Мария",
|
|
||||||
last_name: "Петрова",
|
|
||||||
created_at: "2024-02-01T10:00:00.000Z",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ import {
|
|||||||
mockCurrentUser,
|
mockCurrentUser,
|
||||||
mockTokens,
|
mockTokens,
|
||||||
mockTargetChannels,
|
mockTargetChannels,
|
||||||
getMockTargetChannelDetail,
|
|
||||||
mockExternalChannels,
|
mockExternalChannels,
|
||||||
getMockExternalChannelDetail,
|
|
||||||
mockCreatives,
|
mockCreatives,
|
||||||
getMockCreativeDetail,
|
mockPlacements,
|
||||||
mockPurchases,
|
mockSubscriptions,
|
||||||
getMockPurchaseDetail,
|
mockViewsHistory,
|
||||||
mockAnalyticsOverview,
|
mockAnalyticsOverview,
|
||||||
mockCostsReportByDay,
|
mockCostsReportByDay,
|
||||||
mockCostsReportByWeek,
|
mockCostsReportByWeek,
|
||||||
@@ -28,22 +26,21 @@ import type {
|
|||||||
TargetChannel,
|
TargetChannel,
|
||||||
ExternalChannel,
|
ExternalChannel,
|
||||||
Creative,
|
Creative,
|
||||||
Purchase,
|
Placement,
|
||||||
PurchaseCreateRequest,
|
PlacementCreateRequest,
|
||||||
PurchaseCreateResponse,
|
PlacementUpdateRequest,
|
||||||
CreativeCreateRequest,
|
CreativeCreateRequest,
|
||||||
ExternalChannelCreateRequest,
|
ExternalChannelCreateRequest,
|
||||||
PurchaseRefreshViewsResponse,
|
ViewsFetchResponse,
|
||||||
ExternalChannelImportResponse,
|
ViewsHistoryResponse,
|
||||||
AuthCompleteRequest,
|
AuthCompleteRequest,
|
||||||
AuthCompleteResponse,
|
AuthCompleteResponse,
|
||||||
AuthRefreshRequest,
|
AuthRefreshRequest,
|
||||||
AuthRefreshResponse,
|
AuthRefreshResponse,
|
||||||
AuthInitResponse,
|
AuthInitResponse,
|
||||||
TargetChannelUpdateRequest,
|
|
||||||
ExternalChannelUpdateRequest,
|
ExternalChannelUpdateRequest,
|
||||||
CreativeUpdateRequest,
|
CreativeUpdateRequest,
|
||||||
PurchaseUpdateRequest,
|
ExternalChannelsListResponse,
|
||||||
} from "@/lib/types/api";
|
} from "@/lib/types/api";
|
||||||
|
|
||||||
// Simulate network delay
|
// 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();
|
await delay();
|
||||||
if (!data.token) {
|
if (!data.token) {
|
||||||
throw mockError("VALIDATION_ERROR", "Token is required");
|
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 {
|
return {
|
||||||
access_token: mockTokens.access_token,
|
access_token: mockTokens.access_token,
|
||||||
refresh_token: mockTokens.refresh_token,
|
|
||||||
user: mockCurrentUser,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -105,44 +108,10 @@ export const mockApiHandlers = {
|
|||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
// Target Channels
|
// Target Channels
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
async getTargetChannels(params?: {
|
async getTargetChannels() {
|
||||||
is_active?: boolean;
|
|
||||||
}): Promise<ListResponse<TargetChannel>> {
|
|
||||||
await delay();
|
await delay();
|
||||||
let filtered = [...mockTargetChannels];
|
|
||||||
|
|
||||||
if (params?.is_active !== undefined) {
|
|
||||||
filtered = filtered.filter((c) => c.is_active === params.is_active);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: filtered,
|
target_channels: mockTargetChannels,
|
||||||
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(),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -158,41 +127,24 @@ export const mockApiHandlers = {
|
|||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
// External Channels
|
// External Channels
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
async getExternalChannels(params?: {
|
async getExternalChannels(
|
||||||
target_channel_id?: string;
|
targetChannelId: string
|
||||||
search?: string;
|
): Promise<ExternalChannelsListResponse> {
|
||||||
}): Promise<ListResponse<ExternalChannel>> {
|
|
||||||
await delay();
|
await delay();
|
||||||
let filtered = [...mockExternalChannels];
|
// Возвращаем все каналы для указанного target channel
|
||||||
|
// В реальном API это будет фильтрация по связям через промежуточную таблицу
|
||||||
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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: filtered,
|
external_channels: mockExternalChannels,
|
||||||
total: filtered.length,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async getExternalChannel(id: string) {
|
async getExternalChannel(id: string): Promise<ExternalChannel> {
|
||||||
await delay();
|
await delay();
|
||||||
const detail = getMockExternalChannelDetail(id);
|
const channel = mockExternalChannels.find((c) => c.id === id);
|
||||||
if (!detail) {
|
if (!channel) {
|
||||||
throw mockError("NOT_FOUND", "External channel not found");
|
throw mockError("NOT_FOUND", "External channel not found");
|
||||||
}
|
}
|
||||||
return detail;
|
return channel;
|
||||||
},
|
},
|
||||||
|
|
||||||
async createExternalChannel(
|
async createExternalChannel(
|
||||||
@@ -201,18 +153,11 @@ export const mockApiHandlers = {
|
|||||||
await delay(500);
|
await delay(500);
|
||||||
const newChannel: ExternalChannel = {
|
const newChannel: ExternalChannel = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
telegram_id: null,
|
telegram_id: data.telegram_id,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
username: data.username || null,
|
username: data.username || null,
|
||||||
link: data.link,
|
|
||||||
subscribers_count: data.subscribers_count || null,
|
subscribers_count: data.subscribers_count || null,
|
||||||
description: data.description || 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);
|
mockExternalChannels.push(newChannel);
|
||||||
return newChannel;
|
return newChannel;
|
||||||
@@ -230,7 +175,6 @@ export const mockApiHandlers = {
|
|||||||
return {
|
return {
|
||||||
...channel,
|
...channel,
|
||||||
...data,
|
...data,
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -244,42 +188,20 @@ export const mockApiHandlers = {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
async importExternalChannels(
|
async updateExternalChannelLinks(
|
||||||
file: File,
|
id: string,
|
||||||
targetChannelId: string
|
data: {
|
||||||
): Promise<ExternalChannelImportResponse> {
|
add_target_channel_ids?: string[];
|
||||||
await delay(1500); // Longer delay for file processing
|
remove_target_channel_ids?: string[];
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
): Promise<ExternalChannel> {
|
||||||
return {
|
await delay();
|
||||||
imported: importCount,
|
const channel = mockExternalChannels.find((c) => c.id === id);
|
||||||
skipped: 1,
|
if (!channel) {
|
||||||
errors: ["Строка 15: некорректный формат ссылки"],
|
throw mockError("NOT_FOUND", "External channel not found");
|
||||||
channels: imported,
|
}
|
||||||
};
|
// В упрощенной версии просто возвращаем канал
|
||||||
|
return channel;
|
||||||
},
|
},
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
@@ -287,8 +209,8 @@ export const mockApiHandlers = {
|
|||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
async getCreatives(params?: {
|
async getCreatives(params?: {
|
||||||
target_channel_id?: string;
|
target_channel_id?: string;
|
||||||
is_archived?: boolean;
|
include_archived?: boolean;
|
||||||
}): Promise<ListResponse<Creative>> {
|
}) {
|
||||||
await delay();
|
await delay();
|
||||||
let filtered = [...mockCreatives];
|
let filtered = [...mockCreatives];
|
||||||
|
|
||||||
@@ -298,36 +220,27 @@ export const mockApiHandlers = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params?.is_archived !== undefined) {
|
if (!params?.include_archived) {
|
||||||
filtered = filtered.filter((c) => c.is_archived === params.is_archived);
|
filtered = filtered.filter((c) => c.status !== "archived");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: filtered,
|
creatives: filtered,
|
||||||
total: filtered.length,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async getCreative(id: string) {
|
async getCreative(id: string): Promise<Creative> {
|
||||||
await delay();
|
await delay();
|
||||||
const detail = getMockCreativeDetail(id);
|
const creative = mockCreatives.find((c) => c.id === id);
|
||||||
if (!detail) {
|
if (!creative) {
|
||||||
throw mockError("NOT_FOUND", "Creative not found");
|
throw mockError("NOT_FOUND", "Creative not found");
|
||||||
}
|
}
|
||||||
return detail;
|
return creative;
|
||||||
},
|
},
|
||||||
|
|
||||||
async createCreative(data: CreativeCreateRequest): Promise<Creative> {
|
async createCreative(data: CreativeCreateRequest): Promise<Creative> {
|
||||||
await delay(500);
|
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(
|
const targetChannel = mockTargetChannels.find(
|
||||||
(c) => c.id === data.target_channel_id
|
(c) => c.id === data.target_channel_id
|
||||||
);
|
);
|
||||||
@@ -340,13 +253,10 @@ export const mockApiHandlers = {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
text: data.text,
|
text: data.text,
|
||||||
target_channel_id: data.target_channel_id,
|
target_channel_id: data.target_channel_id,
|
||||||
is_archived: false,
|
target_channel_title: targetChannel.title,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
updated_at: new Date().toISOString(),
|
status: "active",
|
||||||
target_channel: targetChannel,
|
placements_count: 0,
|
||||||
total_purchases: 0,
|
|
||||||
total_subscriptions: 0,
|
|
||||||
avg_cpf: null,
|
|
||||||
};
|
};
|
||||||
mockCreatives.push(newCreative);
|
mockCreatives.push(newCreative);
|
||||||
return newCreative;
|
return newCreative;
|
||||||
@@ -372,7 +282,6 @@ export const mockApiHandlers = {
|
|||||||
return {
|
return {
|
||||||
...creative,
|
...creative,
|
||||||
...data,
|
...data,
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -383,10 +292,10 @@ export const mockApiHandlers = {
|
|||||||
throw mockError("NOT_FOUND", "Creative not found");
|
throw mockError("NOT_FOUND", "Creative not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (creative.total_purchases > 0) {
|
if (creative.placements_count > 0) {
|
||||||
throw mockError(
|
throw mockError(
|
||||||
"CREATIVE_IN_USE",
|
"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;
|
target_channel_id?: string;
|
||||||
external_channel_id?: string;
|
external_channel_id?: string;
|
||||||
creative_id?: string;
|
creative_id?: string;
|
||||||
is_archived?: boolean;
|
include_archived?: boolean;
|
||||||
date_from?: string;
|
}) {
|
||||||
date_to?: string;
|
|
||||||
sort?: string;
|
|
||||||
order?: "asc" | "desc";
|
|
||||||
}): Promise<ListResponse<Purchase>> {
|
|
||||||
await delay();
|
await delay();
|
||||||
let filtered = [...mockPurchases];
|
let filtered = [...mockPlacements];
|
||||||
|
|
||||||
if (params?.target_channel_id) {
|
if (params?.target_channel_id) {
|
||||||
filtered = filtered.filter(
|
filtered = filtered.filter(
|
||||||
@@ -427,62 +332,25 @@ export const mockApiHandlers = {
|
|||||||
filtered = filtered.filter((p) => p.creative_id === params.creative_id);
|
filtered = filtered.filter((p) => p.creative_id === params.creative_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params?.is_archived !== undefined) {
|
if (!params?.include_archived) {
|
||||||
filtered = filtered.filter((p) => p.is_archived === params.is_archived);
|
filtered = filtered.filter((p) => p.status !== "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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: filtered,
|
placements: filtered,
|
||||||
total: filtered.length,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async getPurchase(id: string) {
|
async getPlacement(id: string): Promise<Placement> {
|
||||||
await delay();
|
await delay();
|
||||||
const detail = getMockPurchaseDetail(id);
|
const placement = mockPlacements.find((p) => p.id === id);
|
||||||
if (!detail) {
|
if (!placement) {
|
||||||
throw mockError("NOT_FOUND", "Purchase not found");
|
throw mockError("NOT_FOUND", "Placement not found");
|
||||||
}
|
}
|
||||||
return detail;
|
return placement;
|
||||||
},
|
},
|
||||||
|
|
||||||
async createPurchase(
|
async createPlacement(data: PlacementCreateRequest): Promise<Placement> {
|
||||||
data: PurchaseCreateRequest
|
|
||||||
): Promise<PurchaseCreateResponse> {
|
|
||||||
await delay(800);
|
await delay(800);
|
||||||
|
|
||||||
const targetChannel = mockTargetChannels.find(
|
const targetChannel = mockTargetChannels.find(
|
||||||
@@ -502,85 +370,111 @@ export const mockApiHandlers = {
|
|||||||
.toString(36)
|
.toString(36)
|
||||||
.substring(2, 15)}`;
|
.substring(2, 15)}`;
|
||||||
|
|
||||||
const newPurchase: Purchase = {
|
const newPlacement: Placement = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
target_channel_id: data.target_channel_id,
|
target_channel_id: data.target_channel_id,
|
||||||
|
target_channel_title: targetChannel.title,
|
||||||
external_channel_id: data.external_channel_id,
|
external_channel_id: data.external_channel_id,
|
||||||
|
external_channel_title: externalChannel.title,
|
||||||
creative_id: data.creative_id,
|
creative_id: data.creative_id,
|
||||||
scheduled_date: data.scheduled_date || null,
|
creative_name: creative.name,
|
||||||
actual_date: data.actual_date || null,
|
placement_date: data.placement_date,
|
||||||
cost: data.cost || null,
|
cost: data.cost || null,
|
||||||
comment: data.comment || 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: inviteLink,
|
||||||
invite_link_type: data.invite_link_type,
|
status: "active",
|
||||||
is_archived: false,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
target_channel: targetChannel,
|
|
||||||
external_channel: externalChannel,
|
|
||||||
creative: creative,
|
|
||||||
subscriptions_count: 0,
|
subscriptions_count: 0,
|
||||||
views_count: null,
|
views_count: null,
|
||||||
cpf: null,
|
views_availability: "unknown",
|
||||||
cpm: null,
|
last_views_fetch_at: null,
|
||||||
conversion_rate: null,
|
created_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
mockPurchases.push(newPurchase);
|
mockPlacements.push(newPlacement);
|
||||||
|
return newPlacement;
|
||||||
// Format message with invite link
|
|
||||||
const formattedMessage = creative.text.replace("{link}", inviteLink);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...newPurchase,
|
|
||||||
formatted_message: formattedMessage,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async updatePurchase(
|
async updatePlacement(
|
||||||
id: string,
|
id: string,
|
||||||
data: PurchaseUpdateRequest
|
data: PlacementUpdateRequest
|
||||||
): Promise<Purchase> {
|
): Promise<Placement> {
|
||||||
await delay();
|
await delay();
|
||||||
const purchase = mockPurchases.find((p) => p.id === id);
|
const placement = mockPlacements.find((p) => p.id === id);
|
||||||
if (!purchase) {
|
if (!placement) {
|
||||||
throw mockError("NOT_FOUND", "Purchase not found");
|
throw mockError("NOT_FOUND", "Placement not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...purchase,
|
...placement,
|
||||||
...data,
|
...data,
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async deletePurchase(id: string): Promise<SuccessResponse> {
|
async deletePlacement(id: string): Promise<SuccessResponse> {
|
||||||
await delay();
|
await delay();
|
||||||
const index = mockPurchases.findIndex((p) => p.id === id);
|
const index = mockPlacements.findIndex((p) => p.id === id);
|
||||||
if (index === -1) {
|
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 };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
async refreshPurchaseViews(
|
async fetchPlacementViews(id: string): Promise<ViewsFetchResponse> {
|
||||||
id: string
|
|
||||||
): Promise<PurchaseRefreshViewsResponse> {
|
|
||||||
await delay(1000);
|
await delay(1000);
|
||||||
const purchase = mockPurchases.find((p) => p.id === id);
|
const placement = mockPlacements.find((p) => p.id === id);
|
||||||
if (!purchase) {
|
if (!placement) {
|
||||||
throw mockError("NOT_FOUND", "Purchase not found");
|
throw mockError("NOT_FOUND", "Placement not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock: add some random views
|
// Mock: add some random views
|
||||||
const newViews =
|
const newViews =
|
||||||
(purchase.views_count || 0) + Math.floor(Math.random() * 500);
|
(placement.views_count || 0) + Math.floor(Math.random() * 500);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
views: newViews,
|
placement_id: id,
|
||||||
|
views_count: newViews,
|
||||||
|
views_availability: "available",
|
||||||
fetched_at: new Date().toISOString(),
|
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/channels";
|
||||||
export * from "./data/external-channels";
|
export * from "./data/external-channels";
|
||||||
export * from "./data/creatives";
|
export * from "./data/creatives";
|
||||||
export * from "./data/purchases";
|
export * from "./data/placements";
|
||||||
export * from "./data/analytics";
|
export * from "./data/analytics";
|
||||||
|
|||||||
198
lib/types/api.ts
198
lib/types/api.ts
@@ -7,12 +7,9 @@
|
|||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string; // UUID
|
||||||
telegram_id: number;
|
telegram_id: number;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
first_name: string;
|
|
||||||
last_name: string | null;
|
|
||||||
created_at: string; // ISO 8601
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthInitResponse {
|
export interface AuthInitResponse {
|
||||||
@@ -42,189 +39,172 @@ export interface AuthRefreshResponse {
|
|||||||
// Target Channel
|
// Target Channel
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Основная структура как возвращает API
|
||||||
export interface TargetChannel {
|
export interface TargetChannel {
|
||||||
id: string;
|
id: string; // UUID
|
||||||
telegram_id: number;
|
telegram_id: number;
|
||||||
title: string;
|
title: string;
|
||||||
username: string | null; // @channel_name
|
username: string | null;
|
||||||
description: string | null;
|
|
||||||
is_active: boolean;
|
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 {
|
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 {
|
export interface TargetChannelUpdateRequest {
|
||||||
is_active: boolean;
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TargetChannelsListResponse {
|
||||||
|
target_channels: TargetChannel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// External Channel
|
// External Channel
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Основная структура как возвращает API
|
||||||
export interface ExternalChannel {
|
export interface ExternalChannel {
|
||||||
id: string;
|
id: string; // UUID
|
||||||
telegram_id: number | null;
|
telegram_id: number;
|
||||||
title: string;
|
title: string;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
link: string; // t.me link or custom
|
|
||||||
subscribers_count: number | null;
|
|
||||||
description: string | null;
|
description: string | null;
|
||||||
created_at: string;
|
subscribers_count: number | null;
|
||||||
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[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExternalChannelCreateRequest {
|
export interface ExternalChannelCreateRequest {
|
||||||
|
telegram_id: number;
|
||||||
title: string;
|
title: string;
|
||||||
username?: string;
|
username?: string | null;
|
||||||
link: string;
|
description?: string | null;
|
||||||
subscribers_count?: number;
|
subscribers_count?: number | null;
|
||||||
description?: string;
|
target_channel_ids: string[]; // UUID[]
|
||||||
target_channel_ids: string[]; // Привязка к целевым каналам
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExternalChannelUpdateRequest {
|
export interface ExternalChannelUpdateRequest {
|
||||||
title?: string;
|
title?: string;
|
||||||
username?: string;
|
username?: string | null;
|
||||||
link?: string;
|
description?: string | null;
|
||||||
subscribers_count?: number;
|
subscribers_count?: number | null;
|
||||||
description?: string;
|
|
||||||
target_channel_ids?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExternalChannelImportResponse {
|
export interface ExternalChannelLinksUpdateRequest {
|
||||||
imported: number;
|
add_target_channel_ids?: string[];
|
||||||
skipped: number;
|
remove_target_channel_ids?: string[];
|
||||||
errors: string[];
|
}
|
||||||
channels: ExternalChannel[];
|
|
||||||
|
export interface ExternalChannelsListResponse {
|
||||||
|
external_channels: ExternalChannel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Creative
|
// Creative
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type CreativeStatus = "active" | "archived";
|
||||||
|
|
||||||
export interface Creative {
|
export interface Creative {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
text: string; // Template with {link} placeholder
|
text: string;
|
||||||
target_channel_id: string;
|
target_channel_id: string;
|
||||||
is_archived: boolean;
|
target_channel_title: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
status: CreativeStatus;
|
||||||
// Relations
|
placements_count: number;
|
||||||
target_channel: TargetChannel;
|
|
||||||
// Stats
|
|
||||||
total_purchases: number;
|
|
||||||
total_subscriptions: number;
|
|
||||||
avg_cpf: number | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreativeDetail extends Creative {
|
export interface CreativesListResponse {
|
||||||
purchases: Purchase[];
|
creatives: Creative[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreativeCreateRequest {
|
export interface CreativeCreateRequest {
|
||||||
name: string;
|
name: string;
|
||||||
text: string; // Must contain {link} placeholder
|
text: string;
|
||||||
target_channel_id: string;
|
target_channel_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreativeUpdateRequest {
|
export interface CreativeUpdateRequest {
|
||||||
name?: string;
|
name?: string;
|
||||||
text?: 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;
|
id: string;
|
||||||
target_channel_id: string;
|
target_channel_id: string;
|
||||||
|
target_channel_title: string;
|
||||||
external_channel_id: string;
|
external_channel_id: string;
|
||||||
|
external_channel_title: string;
|
||||||
creative_id: string;
|
creative_id: string;
|
||||||
// Purchase details
|
creative_name: string;
|
||||||
scheduled_date: string | null; // ISO 8601
|
placement_date: string;
|
||||||
actual_date: string | null;
|
cost: number | null;
|
||||||
cost: number | null; // in rubles
|
|
||||||
comment: string | null;
|
comment: string | null;
|
||||||
post_link: string | null; // Link to ad post in external channel
|
ad_post_url: string | null;
|
||||||
invite_link: string; // Generated invite link
|
invite_link_type: InviteLinkType;
|
||||||
invite_link_type: InviteLinkType; // with bot approval or not
|
invite_link: string;
|
||||||
is_archived: boolean;
|
status: PlacementStatus;
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
// Relations
|
|
||||||
target_channel: TargetChannel;
|
|
||||||
external_channel: ExternalChannel;
|
|
||||||
creative: Creative;
|
|
||||||
// Stats
|
|
||||||
subscriptions_count: number;
|
subscriptions_count: number;
|
||||||
views_count: number | null;
|
views_count: number | null;
|
||||||
cpf: number | null; // Cost Per Follower
|
views_availability: ViewsAvailability;
|
||||||
cpm: number | null; // Cost Per Mile (1000 views)
|
last_views_fetch_at: string | null;
|
||||||
conversion_rate: number | null; // subscriptions / views * 100
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PurchaseDetail extends Purchase {
|
export interface PlacementsListResponse {
|
||||||
subscriptions: Subscription[];
|
placements: Placement[];
|
||||||
views_history: ViewsSnapshot[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PurchaseCreateRequest {
|
export interface PlacementCreateRequest {
|
||||||
target_channel_id: string;
|
target_channel_id: string;
|
||||||
external_channel_id: string;
|
external_channel_id: string;
|
||||||
creative_id: string;
|
creative_id: string;
|
||||||
scheduled_date?: string;
|
placement_date: string;
|
||||||
actual_date?: string;
|
cost?: number | null;
|
||||||
cost?: number;
|
comment?: string | null;
|
||||||
comment?: string;
|
ad_post_url?: string | null;
|
||||||
post_link?: string;
|
invite_link_type?: InviteLinkType;
|
||||||
invite_link_type: InviteLinkType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PurchaseCreateResponse extends Purchase {
|
export interface PlacementUpdateRequest {
|
||||||
formatted_message: string; // Creative text with invite_link inserted
|
placement_date?: string;
|
||||||
|
cost?: number | null;
|
||||||
|
comment?: string | null;
|
||||||
|
ad_post_url?: string | null;
|
||||||
|
status?: PlacementStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PurchaseUpdateRequest {
|
// Legacy aliases for backwards compatibility
|
||||||
scheduled_date?: string;
|
export type Purchase = Placement;
|
||||||
actual_date?: string;
|
export type PurchaseCreateRequest = PlacementCreateRequest;
|
||||||
cost?: number;
|
export type PurchaseUpdateRequest = PlacementUpdateRequest;
|
||||||
comment?: string;
|
|
||||||
post_link?: string;
|
|
||||||
is_archived?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PurchaseRefreshViewsResponse {
|
export interface ViewsFetchResponse {
|
||||||
views: number;
|
placement_id: string;
|
||||||
|
views_count: number | null;
|
||||||
|
views_availability: ViewsAvailability;
|
||||||
fetched_at: string;
|
fetched_at: string;
|
||||||
|
error_message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewsHistoryResponse {
|
||||||
|
histories: ViewsSnapshot[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -372,7 +352,7 @@ export interface CreativeQueryParams {
|
|||||||
is_archived?: boolean;
|
is_archived?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PurchaseQueryParams {
|
export interface PlacementQueryParams {
|
||||||
target_channel_id?: string;
|
target_channel_id?: string;
|
||||||
external_channel_id?: string;
|
external_channel_id?: string;
|
||||||
creative_id?: string;
|
creative_id?: string;
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const API_URL =
|
export const API_URL =
|
||||||
process.env.NEXT_PUBLIC_API_URL || "https://api.tgex.app/v1";
|
process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";
|
||||||
export const USE_MOCKS = process.env.NEXT_PUBLIC_USE_MOCKS === "true";
|
export const USE_MOCKS =
|
||||||
export const BOT_USERNAME = process.env.NEXT_PUBLIC_BOT_USERNAME || "tgex_bot";
|
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
|
// Local Storage Keys
|
||||||
export const STORAGE_KEYS = {
|
export const STORAGE_KEYS = {
|
||||||
@@ -76,7 +78,7 @@ export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
|||||||
export const ROUTES = {
|
export const ROUTES = {
|
||||||
HOME: "/",
|
HOME: "/",
|
||||||
LOGIN: "/login",
|
LOGIN: "/login",
|
||||||
AUTH_COMPLETE: "/auth-complete",
|
AUTH_COMPLETE: "/auth/complete",
|
||||||
|
|
||||||
CHANNELS: "/channels",
|
CHANNELS: "/channels",
|
||||||
CHANNEL_DETAIL: (id: string) => `/channels/${id}`,
|
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-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@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-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "16.0.1",
|
"next": "16.0.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"papaparse": "^5.5.3",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
@@ -45,11 +47,13 @@
|
|||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^4.1.12"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/papaparse": "^5.5.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
|
|||||||
116
pnpm-lock.yaml
generated
116
pnpm-lock.yaml
generated
@@ -44,6 +44,9 @@ importers:
|
|||||||
'@radix-ui/react-popover':
|
'@radix-ui/react-popover':
|
||||||
specifier: ^1.1.15
|
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)
|
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':
|
'@radix-ui/react-radio-group':
|
||||||
specifier: ^1.3.8
|
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)
|
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:
|
next-themes:
|
||||||
specifier: ^0.4.6
|
specifier: ^0.4.6
|
||||||
version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
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:
|
react:
|
||||||
specifier: 19.2.0
|
specifier: 19.2.0
|
||||||
version: 19.2.0
|
version: 19.2.0
|
||||||
@@ -116,6 +122,9 @@ importers:
|
|||||||
vaul:
|
vaul:
|
||||||
specifier: ^1.1.2
|
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)
|
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:
|
zod:
|
||||||
specifier: ^4.1.12
|
specifier: ^4.1.12
|
||||||
version: 4.1.12
|
version: 4.1.12
|
||||||
@@ -126,6 +135,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^20
|
specifier: ^20
|
||||||
version: 20.19.24
|
version: 20.19.24
|
||||||
|
'@types/papaparse':
|
||||||
|
specifier: ^5.5.0
|
||||||
|
version: 5.5.0
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19
|
specifier: ^19
|
||||||
version: 19.2.2
|
version: 19.2.2
|
||||||
@@ -846,6 +858,19 @@ packages:
|
|||||||
'@types/react-dom':
|
'@types/react-dom':
|
||||||
optional: true
|
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':
|
'@radix-ui/react-radio-group@1.3.8':
|
||||||
resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==}
|
resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1223,6 +1248,9 @@ packages:
|
|||||||
'@types/node@20.19.24':
|
'@types/node@20.19.24':
|
||||||
resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==}
|
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':
|
'@types/react-dom@19.2.2':
|
||||||
resolution: {integrity: sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==}
|
resolution: {integrity: sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1395,6 +1423,10 @@ packages:
|
|||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
adler-32@1.3.1:
|
||||||
|
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
ajv@6.12.6:
|
ajv@6.12.6:
|
||||||
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
||||||
|
|
||||||
@@ -1505,6 +1537,10 @@ packages:
|
|||||||
caniuse-lite@1.0.30001754:
|
caniuse-lite@1.0.30001754:
|
||||||
resolution: {integrity: sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==}
|
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:
|
chalk@4.1.2:
|
||||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1519,6 +1555,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
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:
|
color-convert@2.0.1:
|
||||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||||
engines: {node: '>=7.0.0'}
|
engines: {node: '>=7.0.0'}
|
||||||
@@ -1532,6 +1572,11 @@ packages:
|
|||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
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:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -1878,6 +1923,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
frac@1.1.2:
|
||||||
|
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
function-bind@1.1.2:
|
function-bind@1.1.2:
|
||||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||||
|
|
||||||
@@ -2378,6 +2427,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
papaparse@5.5.3:
|
||||||
|
resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2614,6 +2666,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
stable-hash@0.0.5:
|
||||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||||
|
|
||||||
@@ -2815,10 +2871,23 @@ packages:
|
|||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
wmf@1.0.2:
|
||||||
|
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
word-wrap@1.2.5:
|
word-wrap@1.2.5:
|
||||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||||
|
|
||||||
@@ -3519,6 +3588,16 @@ snapshots:
|
|||||||
'@types/react': 19.2.2
|
'@types/react': 19.2.2
|
||||||
'@types/react-dom': 19.2.2(@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)':
|
'@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:
|
dependencies:
|
||||||
'@radix-ui/primitive': 1.1.3
|
'@radix-ui/primitive': 1.1.3
|
||||||
@@ -3871,6 +3950,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
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)':
|
'@types/react-dom@19.2.2(@types/react@19.2.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.2.2
|
'@types/react': 19.2.2
|
||||||
@@ -4037,6 +4120,8 @@ snapshots:
|
|||||||
|
|
||||||
acorn@8.15.0: {}
|
acorn@8.15.0: {}
|
||||||
|
|
||||||
|
adler-32@1.3.1: {}
|
||||||
|
|
||||||
ajv@6.12.6:
|
ajv@6.12.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
@@ -4181,6 +4266,11 @@ snapshots:
|
|||||||
|
|
||||||
caniuse-lite@1.0.30001754: {}
|
caniuse-lite@1.0.30001754: {}
|
||||||
|
|
||||||
|
cfb@1.2.2:
|
||||||
|
dependencies:
|
||||||
|
adler-32: 1.3.1
|
||||||
|
crc-32: 1.2.2
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
@@ -4194,6 +4284,8 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
|
codepage@1.15.0: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
@@ -4204,6 +4296,8 @@ snapshots:
|
|||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
|
crc-32@1.2.2: {}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -4692,6 +4786,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-callable: 1.2.7
|
is-callable: 1.2.7
|
||||||
|
|
||||||
|
frac@1.1.2: {}
|
||||||
|
|
||||||
function-bind@1.1.2: {}
|
function-bind@1.1.2: {}
|
||||||
|
|
||||||
function.prototype.name@1.1.8:
|
function.prototype.name@1.1.8:
|
||||||
@@ -5173,6 +5269,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-limit: 3.1.0
|
p-limit: 3.1.0
|
||||||
|
|
||||||
|
papaparse@5.5.3: {}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
callsites: 3.1.0
|
callsites: 3.1.0
|
||||||
@@ -5460,6 +5558,10 @@ snapshots:
|
|||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
ssf@0.11.2:
|
||||||
|
dependencies:
|
||||||
|
frac: 1.1.2
|
||||||
|
|
||||||
stable-hash@0.0.5: {}
|
stable-hash@0.0.5: {}
|
||||||
|
|
||||||
stop-iteration-iterator@1.1.0:
|
stop-iteration-iterator@1.1.0:
|
||||||
@@ -5749,8 +5851,22 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|
||||||
|
wmf@1.0.2: {}
|
||||||
|
|
||||||
word-wrap@1.2.5: {}
|
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: {}
|
yallist@3.1.1: {}
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user