improvement for the backend api

This commit is contained in:
ivannoskov
2025-11-19 12:56:04 +03:00
parent b0a9934220
commit d4672ea32b
40 changed files with 2383 additions and 3313 deletions

View File

@@ -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
View 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. Нужно добавить бота в канал как администратора, и он автоматически зарегистрирует канал в системе.

View File

@@ -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

View File

@@ -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 || "Ошибка авторизации");
} }
}; };

View File

@@ -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 || "Ошибка загрузки аналитики");

View File

@@ -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>
</>
);
} }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);
} }

View File

@@ -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" />

View File

@@ -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>
</>
);
} }

View File

@@ -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>
</> </>
); );

View File

@@ -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>

View File

@@ -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>
</> </>
); );

View File

@@ -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>

View File

@@ -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">

View File

@@ -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 {

View File

@@ -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`,
}} }}
/> />
)} )}

View 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 }

View File

@@ -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;

View File

@@ -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}`),
}; };

View File

@@ -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();

View File

@@ -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

View File

@@ -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
);
},
}; };

View File

@@ -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
View 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;

View File

@@ -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`),
};

View File

@@ -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,
},
],
};
};

View File

@@ -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
};
};

View File

@@ -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
};
};

View 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",
},
];

View File

@@ -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),
};
};

View File

@@ -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",
}, },
]; ];

View File

@@ -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(),
}; };
}, },

View File

@@ -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";

View File

@@ -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;

View File

@@ -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
View 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();
}
};

View File

@@ -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
View File

@@ -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: {}