feat: all project setup
This commit is contained in:
36
.cursor/rules/frontend-rules.mdc
Normal file
36
.cursor/rules/frontend-rules.mdc
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
You are a Senior Front-End Developer and an Expert in NextJS, TypeScript, HTML, CSS and modern UI/UX frameworks (e.g., TailwindCSS, Shadcn, Radix). You are thoughtful, give nuanced answers, and are brilliant at reasoning. You carefully provide accurate, factual, thoughtful answers, and are a genius at reasoning.
|
||||
|
||||
- Follow the user’s requirements carefully & to the letter.
|
||||
- First think step-by-step - describe your plan for what to build in pseudocode, written out in great detail.
|
||||
- Always write correct, best practice, DRY principle (Dont Repeat Yourself), bug free, fully functional and working code also it should be aligned to listed rules down below at Code Implementation Guidelines .
|
||||
- Focus on easy and readability code, over being performant.
|
||||
- Fully implement all requested functionality.
|
||||
- Leave NO todo’s, placeholders or missing pieces.
|
||||
- Ensure code is complete! Verify thoroughly finalised.
|
||||
- Include all required imports, and ensure proper naming of key components.
|
||||
- Be concise Minimize any other prose.
|
||||
- If you think there might not be a correct answer, you say so.
|
||||
- If you do not know the answer, say so, instead of guessing.
|
||||
|
||||
### Coding Environment
|
||||
|
||||
The user asks questions about the following coding languages:
|
||||
|
||||
- NextJS
|
||||
- TypeScript
|
||||
- TailwindCSS
|
||||
|
||||
### Code Implementation Guidelines
|
||||
|
||||
Follow these rules when you write code:
|
||||
|
||||
- Use early returns whenever possible to make the code more readable.
|
||||
- Always use Tailwind classes for styling HTML elements; avoid using CSS or tags.
|
||||
- Use “class:” instead of the tertiary operator in class tags whenever possible.
|
||||
- Use descriptive variable and function/const names. Also, event functions should be named with a “handle” prefix, like “handleClick” for onClick and “handleKeyDown” for onKeyDown.
|
||||
- Implement accessibility features on elements. For example, a tag should have a tabindex=“0”, aria-label, on:click, and on:keydown, and similar attributes.
|
||||
- Use consts instead of functions, for example, “const toggle = () =>”. Also, define a type if possible.
|
||||
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
739
API_DOCUMENTATION.md
Normal file
739
API_DOCUMENTATION.md
Normal file
@@ -0,0 +1,739 @@
|
||||
# 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
|
||||
352
IMPLEMENTATION_PLAN.md
Normal file
352
IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,352 @@
|
||||
# План реализации фронтенда
|
||||
|
||||
## Стек технологий
|
||||
|
||||
- **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
|
||||
36
README.md
36
README.md
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
|
||||
115
app/(auth)/auth-complete/page.tsx
Normal file
115
app/(auth)/auth-complete/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Auth Complete Page
|
||||
// ============================================================================
|
||||
|
||||
import React, { useEffect, useState, Suspense } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { AlertCircle, CheckCircle, Loader2 } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function AuthCompleteContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
const [status, setStatus] = useState<"loading" | "success" | "error">(
|
||||
"loading"
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get("token");
|
||||
|
||||
if (!token) {
|
||||
setStatus("error");
|
||||
setError("Токен авторизации не найден");
|
||||
return;
|
||||
}
|
||||
|
||||
const completeAuth = async () => {
|
||||
try {
|
||||
await login(token);
|
||||
setStatus("success");
|
||||
|
||||
// Redirect after short delay
|
||||
setTimeout(() => {
|
||||
router.push("/");
|
||||
}, 1500);
|
||||
} catch (err: any) {
|
||||
setStatus("error");
|
||||
setError(err?.error?.message || "Ошибка авторизации");
|
||||
}
|
||||
};
|
||||
|
||||
completeAuth();
|
||||
}, [searchParams, login, router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4">
|
||||
{status === "loading" && (
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
)}
|
||||
{status === "success" && (
|
||||
<CheckCircle className="h-12 w-12 text-green-500" />
|
||||
)}
|
||||
{status === "error" && (
|
||||
<AlertCircle className="h-12 w-12 text-destructive" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardTitle>
|
||||
{status === "loading" && "Авторизация..."}
|
||||
{status === "success" && "Успешно!"}
|
||||
{status === "error" && "Ошибка"}
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
{status === "loading" && "Пожалуйста, подождите"}
|
||||
{status === "success" && "Перенаправляем в приложение..."}
|
||||
{status === "error" && error}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{status === "error" && (
|
||||
<CardContent>
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button onClick={() => router.push("/login")} className="w-full">
|
||||
Вернуться к входу
|
||||
</Button>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuthCompletePage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AuthCompleteContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
135
app/(auth)/login/page.tsx
Normal file
135
app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Login Page
|
||||
// ============================================================================
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { USE_MOCKS, BOT_USERNAME } from "@/lib/utils/constants";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertCircle, LogIn, Loader2 } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { loginMock, error: authError } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleTelegramLogin = () => {
|
||||
const telegramUrl = `https://t.me/${BOT_USERNAME}?start=login`;
|
||||
window.open(telegramUrl, "_blank");
|
||||
};
|
||||
|
||||
const handleMockLogin = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
await loginMock();
|
||||
} catch (err: any) {
|
||||
setError(err?.error?.message || "Ошибка входа");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayError = error || authError;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center space-y-2">
|
||||
<div className="mx-auto w-12 h-12 bg-primary rounded-lg flex items-center justify-center mb-2">
|
||||
<span className="text-2xl font-bold text-primary-foreground">
|
||||
TG
|
||||
</span>
|
||||
</div>
|
||||
<CardTitle className="text-2xl">TGEX</CardTitle>
|
||||
<CardDescription>
|
||||
Система управления рекламными закупками в Telegram
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{displayError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{displayError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{USE_MOCKS && (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={handleMockLogin}
|
||||
disabled={loading}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Вход...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="mr-2 h-4 w-4" />
|
||||
Войти как тестовый пользователь
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
или
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleTelegramLogin}
|
||||
variant={USE_MOCKS ? "outline" : "default"}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
<svg
|
||||
className="mr-2 h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.562 8.161c-.18.717-.962 3.767-1.362 5.001-.169.523-.506.697-.831.715-.708.064-1.245-.468-1.931-.918-.988-.646-1.547-1.048-2.508-1.678-.111-.073-.336-.218-.325-.409.009-.169.169-.316.373-.503l3.067-2.854c.234-.217.136-.359-.15-.223l-3.845 2.365c-.516.324-.989.472-1.411.461-.675-.018-1.969-.393-2.937-.719-.688-.232-1.236-.354-1.188-.755.025-.211.325-.426.9-.645 4.089-1.781 6.785-2.954 8.09-3.518 3.853-1.636 4.65-1.918 5.17-1.926.115-.002.371.026.536.161.141.114.18.267.198.375.019.108.042.353.024.545z" />
|
||||
</svg>
|
||||
Войти через Telegram
|
||||
</Button>
|
||||
|
||||
{USE_MOCKS && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
<strong>Режим разработки:</strong> Используются моки данных.
|
||||
Бекенд не требуется.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground mt-4">
|
||||
Войдя в систему, вы соглашаетесь с условиями использования
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
294
app/(dashboard)/analytics/costs/page.tsx
Normal file
294
app/(dashboard)/analytics/costs/page.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Analytics Costs Page
|
||||
// ============================================================================
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { analyticsApi, channelsApi } from "@/lib/api";
|
||||
import { formatCurrency, formatNumber } from "@/lib/utils/format";
|
||||
import { Loader2, AlertCircle, DollarSign } from "lucide-react";
|
||||
import type { CostsReport } from "@/lib/types/api";
|
||||
import type { TargetChannel } from "@/lib/types/api";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
export default function AnalyticsCostsPage() {
|
||||
const [report, setReport] = useState<CostsReport | null>(null);
|
||||
const [channels, setChannels] = useState<TargetChannel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [period, setPeriod] = useState<"day" | "week" | "month">("week");
|
||||
const [targetChannelId, setTargetChannelId] = useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [period, targetChannelId]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [reportData, channelsData] = await Promise.all([
|
||||
analyticsApi.costs({ period, target_channel_id: targetChannelId }),
|
||||
channels.length > 0
|
||||
? Promise.resolve({ data: channels })
|
||||
: channelsApi.list({}),
|
||||
]);
|
||||
setReport(reportData);
|
||||
if (channels.length === 0) {
|
||||
setChannels(channelsData.data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.error?.message || "Ошибка загрузки аналитики");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: "/" },
|
||||
{ label: "Аналитика", href: "/analytics" },
|
||||
{ label: "Затраты" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Затраты на рекламу
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Динамика расходов по периодам
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Фильтры</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
Период группировки
|
||||
</label>
|
||||
<Select
|
||||
value={period}
|
||||
onValueChange={(value: any) => setPeriod(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="day">По дням</SelectItem>
|
||||
<SelectItem value="week">По неделям</SelectItem>
|
||||
<SelectItem value="month">По месяцам</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Целевой канал</label>
|
||||
<Select
|
||||
value={targetChannelId || "all"}
|
||||
onValueChange={(value) =>
|
||||
setTargetChannelId(value === "all" ? undefined : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Все каналы</SelectItem>
|
||||
{channels.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.id}>
|
||||
{channel.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center p-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : report ? (
|
||||
<>
|
||||
<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>
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(report.totalCost)}
|
||||
</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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(report.averageCost)}
|
||||
</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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(report.periods.length)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
С активностью
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>График затрат</CardTitle>
|
||||
<CardDescription>Динамика расходов на рекламу</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<BarChart data={report.periods}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
className="stroke-muted"
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
className="text-xs"
|
||||
tick={{ fill: "hsl(var(--muted-foreground))" }}
|
||||
/>
|
||||
<YAxis
|
||||
className="text-xs"
|
||||
tick={{ fill: "hsl(var(--muted-foreground))" }}
|
||||
tickFormatter={(value) => `₽${formatNumber(value)}`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
formatter={(value: any) => [
|
||||
`₽${formatNumber(value)}`,
|
||||
"Затраты",
|
||||
]}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="total_cost"
|
||||
name="Затраты"
|
||||
fill="hsl(var(--primary))"
|
||||
radius={[8, 8, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Детализация по периодам</CardTitle>
|
||||
<CardDescription>Затраты и количество закупов</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{report.periods.map((period) => (
|
||||
<div
|
||||
key={period.date}
|
||||
className="flex items-center justify-between p-3 rounded-lg border"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{period.date}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatNumber(period.purchases_count)} закупов
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-bold">
|
||||
{formatCurrency(period.total_cost)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
ср. ₽{formatNumber(period.avg_cost)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
348
app/(dashboard)/analytics/page.tsx
Normal file
348
app/(dashboard)/analytics/page.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Analytics Overview Page
|
||||
// ============================================================================
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { analyticsApi } from "@/lib/api";
|
||||
import {
|
||||
formatNumber,
|
||||
formatMetric,
|
||||
formatCurrency,
|
||||
formatPercent,
|
||||
} from "@/lib/utils/format";
|
||||
import {
|
||||
BarChart3,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Users,
|
||||
ShoppingCart,
|
||||
DollarSign,
|
||||
} from "lucide-react";
|
||||
import type { AnalyticsOverview } from "@/lib/types/api";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const [overview, setOverview] = useState<AnalyticsOverview | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadOverview();
|
||||
}, []);
|
||||
|
||||
const loadOverview = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await analyticsApi.overview();
|
||||
setOverview(data);
|
||||
} catch (err: any) {
|
||||
setError(err?.error?.message || "Ошибка загрузки аналитики");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: "/" },
|
||||
{ label: "Аналитика" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !overview) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: "/" },
|
||||
{ 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>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: "/" },
|
||||
{ label: "Аналитика" },
|
||||
{ label: "Обзор" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Аналитика</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Сводная статистика за последний месяц
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg: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>
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(overview.totalPurchases)}
|
||||
</div>
|
||||
<div className="flex items-center text-xs mt-1">
|
||||
{overview.purchasesChange >= 0 ? (
|
||||
<TrendingUp className="h-3 w-3 text-green-600 mr-1" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3 text-red-600 mr-1" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
overview.purchasesChange >= 0
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
>
|
||||
{overview.purchasesChange >= 0 ? "+" : ""}
|
||||
{formatNumber(overview.purchasesChange)}
|
||||
</span>
|
||||
<span className="text-muted-foreground ml-1">
|
||||
за прошлый месяц
|
||||
</span>
|
||||
</div>
|
||||
</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(overview.totalFollowers)}
|
||||
</div>
|
||||
<div className="flex items-center text-xs mt-1">
|
||||
{overview.followersChange >= 0 ? (
|
||||
<TrendingUp className="h-3 w-3 text-green-600 mr-1" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3 text-red-600 mr-1" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
overview.followersChange >= 0
|
||||
? "text-green-600"
|
||||
: "text-red-600"
|
||||
}
|
||||
>
|
||||
{overview.followersChange >= 0 ? "+" : ""}
|
||||
{formatNumber(overview.followersChange)}
|
||||
</span>
|
||||
<span className="text-muted-foreground ml-1">
|
||||
за прошлый месяц
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
₽{formatMetric(overview.averageCpf)}
|
||||
</div>
|
||||
<div className="flex items-center text-xs mt-1">
|
||||
{overview.cpfChange <= 0 ? (
|
||||
<TrendingDown className="h-3 w-3 text-green-600 mr-1" />
|
||||
) : (
|
||||
<TrendingUp className="h-3 w-3 text-red-600 mr-1" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
overview.cpfChange <= 0 ? "text-green-600" : "text-red-600"
|
||||
}
|
||||
>
|
||||
{formatPercent(overview.cpfChange, true)}
|
||||
</span>
|
||||
<span className="text-muted-foreground ml-1">
|
||||
от прошлого месяца
|
||||
</span>
|
||||
</div>
|
||||
</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>
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
₽{formatMetric(overview.averageCpm)}
|
||||
</div>
|
||||
<div className="flex items-center text-xs mt-1">
|
||||
{overview.cpmChange <= 0 ? (
|
||||
<TrendingDown className="h-3 w-3 text-green-600 mr-1" />
|
||||
) : (
|
||||
<TrendingUp className="h-3 w-3 text-red-600 mr-1" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
overview.cpmChange <= 0 ? "text-green-600" : "text-red-600"
|
||||
}
|
||||
>
|
||||
{formatPercent(overview.cpmChange, true)}
|
||||
</span>
|
||||
<span className="text-muted-foreground ml-1">
|
||||
от прошлого месяца
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Топ каналов по CPF</CardTitle>
|
||||
<CardDescription>
|
||||
Самые эффективные внешние каналы
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{overview.topChannelsByCpf?.length > 0 ? (
|
||||
overview.topChannelsByCpf.slice(0, 5).map((item, index) => (
|
||||
<div
|
||||
key={item.channel_id}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="w-8 h-8 rounded-full p-0 flex items-center justify-center"
|
||||
>
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">
|
||||
{item.channel_name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatNumber(item.total_purchases)} закупов
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">
|
||||
₽{formatMetric(item.avg_cpf)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">CPF</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-4">
|
||||
Нет данных
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Топ креативов</CardTitle>
|
||||
<CardDescription>
|
||||
Самые эффективные рекламные креативы
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{overview.topCreativesByCpf?.length > 0 ? (
|
||||
overview.topCreativesByCpf.slice(0, 5).map((item, index) => (
|
||||
<div
|
||||
key={item.creative_id}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="w-8 h-8 rounded-full p-0 flex items-center justify-center"
|
||||
>
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">
|
||||
{item.creative_name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatNumber(item.total_subscriptions)} подписчиков
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">
|
||||
₽{formatMetric(item.avg_cpf)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">CPF</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-4">
|
||||
Нет данных
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
360
app/(dashboard)/channels/[id]/page.tsx
Normal file
360
app/(dashboard)/channels/[id]/page.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Target Channel Detail Page
|
||||
// ============================================================================
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { use } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { channelsApi } from "@/lib/api";
|
||||
import {
|
||||
formatNumber,
|
||||
formatMetric,
|
||||
formatCurrency,
|
||||
formatUsername,
|
||||
formatDate,
|
||||
} from "@/lib/utils/format";
|
||||
import {
|
||||
Target,
|
||||
Users,
|
||||
TrendingUp,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
ExternalLink as ExternalLinkIcon,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import type { TargetChannelDetail } from "@/lib/types/api";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function ChannelDetailPage({ params }: PageProps) {
|
||||
const resolvedParams = use(params);
|
||||
const router = useRouter();
|
||||
const [channel, setChannel] = useState<TargetChannelDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadChannel = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await channelsApi.get(resolvedParams.id);
|
||||
setChannel(data);
|
||||
} catch (err: any) {
|
||||
setError(err?.error?.message || "Ошибка загрузки канала");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadChannel();
|
||||
}, [resolvedParams.id]);
|
||||
|
||||
const handleToggleActive = async () => {
|
||||
if (!channel) return;
|
||||
|
||||
try {
|
||||
const updated = await channelsApi.update(channel.id, {
|
||||
is_active: !channel.is_active,
|
||||
});
|
||||
setChannel({ ...channel, is_active: updated.is_active });
|
||||
} catch (err: any) {
|
||||
alert(err?.error?.message || "Ошибка обновления канала");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!channel) return;
|
||||
if (!confirm("Вы уверены, что хотите отключить этот канал?")) return;
|
||||
|
||||
try {
|
||||
await channelsApi.delete(channel.id);
|
||||
router.push("/channels");
|
||||
} catch (err: any) {
|
||||
alert(err?.error?.message || "Ошибка удаления канала");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Целевые каналы", href: "/channels" },
|
||||
{ label: "Загрузка..." },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !channel) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Целевые каналы", href: "/channels" },
|
||||
{ label: "Ошибка" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error || "Канал не найден"}</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/channels">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Вернуться к каналам
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: "/" },
|
||||
{ label: "Целевые каналы", href: "/channels" },
|
||||
{ label: channel.title },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Target className="h-6 w-6" />
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{channel.title}
|
||||
</h1>
|
||||
<Badge variant={channel.is_active ? "default" : "secondary"}>
|
||||
{channel.is_active ? "Активен" : "Отключен"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
{channel.username ? (
|
||||
<a
|
||||
href={`https://t.me/${channel.username.replace("@", "")}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline flex items-center gap-1"
|
||||
>
|
||||
{formatUsername(channel.username)}
|
||||
<ExternalLinkIcon className="h-3 w-3" />
|
||||
</a>
|
||||
) : (
|
||||
<span>Приватный канал</span>
|
||||
)}
|
||||
<span>•</span>
|
||||
<span>Добавлен {formatDate(channel.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleToggleActive}>
|
||||
{channel.is_active ? "Отключить" : "Включить"}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{channel.description && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Описание</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">{channel.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Всего закупов
|
||||
</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(channel.total_purchases)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
За весь период
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Подписчиков привлечено
|
||||
</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(channel.total_subscriptions)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Всего</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Средний CPF</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
₽{formatMetric(channel.avg_cpf)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Стоимость подписчика
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="stats" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="stats">Статистика по периодам</TabsTrigger>
|
||||
<TabsTrigger value="purchases">Последние закупы</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="stats" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Статистика по периодам</CardTitle>
|
||||
<CardDescription>
|
||||
Динамика привлечения подписчиков
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Период</TableHead>
|
||||
<TableHead className="text-right">Подписчики</TableHead>
|
||||
<TableHead className="text-right">Затраты</TableHead>
|
||||
<TableHead className="text-right">CPF</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{channel.stats_by_period.map((stat, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className="font-medium capitalize">
|
||||
{stat.period}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(stat.subscriptions)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(stat.cost)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
₽{formatMetric(stat.cpf)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="purchases" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Последние закупы</CardTitle>
|
||||
<CardDescription>
|
||||
5 последних рекламных размещений
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{channel.recent_purchases.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
Закупов пока нет
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Внешний канал</TableHead>
|
||||
<TableHead>Дата</TableHead>
|
||||
<TableHead className="text-right">Подписчики</TableHead>
|
||||
<TableHead className="text-right">CPF</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{channel.recent_purchases.map((purchase) => (
|
||||
<TableRow key={purchase.id}>
|
||||
<TableCell className="font-medium">
|
||||
{purchase.external_channel.title}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDate(
|
||||
purchase.actual_date || purchase.scheduled_date
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(purchase.subscriptions_count)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
₽{formatMetric(purchase.cpf)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/purchases/${purchase.id}`}>
|
||||
Открыть
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
281
app/(dashboard)/channels/page.tsx
Normal file
281
app/(dashboard)/channels/page.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Target Channels List Page
|
||||
// ============================================================================
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { channelsApi } from "@/lib/api";
|
||||
import { formatNumber, formatMetric, formatUsername } from "@/lib/utils/format";
|
||||
import {
|
||||
Target,
|
||||
Users,
|
||||
TrendingUp,
|
||||
ExternalLink as ExternalLinkIcon,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Plus,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import type { TargetChannel } from "@/lib/types/api";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
export default function ChannelsPage() {
|
||||
const [channels, setChannels] = useState<TargetChannel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"all" | "active" | "inactive">(
|
||||
"all"
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const loadChannels = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await channelsApi.list();
|
||||
setChannels(response.data);
|
||||
} catch (err: any) {
|
||||
setError(err?.error?.message || "Ошибка загрузки каналов");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadChannels();
|
||||
}, []);
|
||||
|
||||
const filteredChannels = channels.filter((channel) => {
|
||||
if (activeTab === "active") return channel.is_active;
|
||||
if (activeTab === "inactive") return !channel.is_active;
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleToggleActive = async (id: string, isActive: boolean) => {
|
||||
try {
|
||||
await channelsApi.update(id, { is_active: !isActive });
|
||||
setChannels((prev) =>
|
||||
prev.map((ch) => (ch.id === id ? { ...ch, is_active: !isActive } : ch))
|
||||
);
|
||||
} catch (err: any) {
|
||||
alert(err?.error?.message || "Ошибка обновления канала");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: "/" },
|
||||
{ label: "Целевые каналы" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Целевые каналы
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Каналы, в которые вы приводите подписчиков
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Подключение нового канала</CardTitle>
|
||||
<CardDescription>
|
||||
Добавьте бота в свой Telegram-канал с правами администратора
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<a
|
||||
href="https://t.me/tgex_bot?startgroup=admin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Подключить канал
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm">
|
||||
<li>Откройте свой Telegram-канал</li>
|
||||
<li>Добавьте @tgex_bot в администраторы канала</li>
|
||||
<li>
|
||||
Предоставьте боту права:{" "}
|
||||
<strong>создавать инвайт-ссылки</strong> и{" "}
|
||||
<strong>видеть вступления</strong>
|
||||
</li>
|
||||
<li>Канал автоматически появится в списке ниже</li>
|
||||
</ol>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as any)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">Все ({channels.length})</TabsTrigger>
|
||||
<TabsTrigger value="active">
|
||||
Активные ({channels.filter((c) => c.is_active).length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="inactive">
|
||||
Отключенные ({channels.filter((c) => !c.is_active).length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={activeTab} className="mt-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredChannels.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center p-12">
|
||||
<Target className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-lg font-medium text-muted-foreground">
|
||||
{activeTab === "all" && "Нет подключенных каналов"}
|
||||
{activeTab === "active" && "Нет активных каналов"}
|
||||
{activeTab === "inactive" && "Нет отключенных каналов"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Добавьте бота в свой канал, чтобы начать работу
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredChannels.map((channel) => (
|
||||
<Card
|
||||
key={channel.id}
|
||||
className="hover:shadow-md transition-shadow"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="truncate flex items-center gap-2">
|
||||
<Target className="h-4 w-4 shrink-0" />
|
||||
{channel.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="truncate mt-1">
|
||||
{channel.username ? (
|
||||
<a
|
||||
href={`https://t.me/${channel.username.replace(
|
||||
"@",
|
||||
""
|
||||
)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{formatUsername(channel.username)}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
Приватный канал
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge
|
||||
variant={channel.is_active ? "default" : "secondary"}
|
||||
>
|
||||
{channel.is_active ? "Активен" : "Отключен"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{channel.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{channel.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-lg border bg-muted/50 p-2">
|
||||
<div className="text-sm font-medium">
|
||||
{formatNumber(channel.total_purchases)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Закупов
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/50 p-2">
|
||||
<div className="text-sm font-medium">
|
||||
{formatNumber(channel.total_subscriptions)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Подписчиков
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/50 p-2">
|
||||
<div className="text-sm font-medium">
|
||||
₽{formatMetric(channel.avg_cpf)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
CPF
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
<Link href={`/channels/${channel.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Подробнее
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleToggleActive(channel.id, channel.is_active)
|
||||
}
|
||||
>
|
||||
{channel.is_active ? "Отключить" : "Включить"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
321
app/(dashboard)/creatives/[id]/page.tsx
Normal file
321
app/(dashboard)/creatives/[id]/page.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Creative Detail Page
|
||||
// ============================================================================
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { use } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { creativesApi } from "@/lib/api";
|
||||
import {
|
||||
formatNumber,
|
||||
formatMetric,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
} from "@/lib/utils/format";
|
||||
import {
|
||||
Folder,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Archive,
|
||||
Trash2,
|
||||
BarChart3,
|
||||
Users,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import type { CreativeDetail } from "@/lib/types/api";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function CreativeDetailPage({ params }: PageProps) {
|
||||
const resolvedParams = use(params);
|
||||
const router = useRouter();
|
||||
const [creative, setCreative] = useState<CreativeDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCreative = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await creativesApi.get(resolvedParams.id);
|
||||
setCreative(data);
|
||||
} catch (err: any) {
|
||||
setError(err?.error?.message || "Ошибка загрузки креатива");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCreative();
|
||||
}, [resolvedParams.id]);
|
||||
|
||||
const handleArchive = async () => {
|
||||
if (!creative) return;
|
||||
|
||||
try {
|
||||
await creativesApi.update(creative.id, {
|
||||
is_archived: !creative.is_archived,
|
||||
});
|
||||
setCreative({ ...creative, is_archived: !creative.is_archived });
|
||||
} catch (err: any) {
|
||||
alert(err?.error?.message || "Ошибка обновления креатива");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!creative) return;
|
||||
if (!confirm(`Удалить креатив "${creative.name}"?`)) return;
|
||||
|
||||
try {
|
||||
await creativesApi.delete(creative.id);
|
||||
router.push("/creatives");
|
||||
} catch (err: any) {
|
||||
alert(err?.error?.message || "Ошибка удаления креатива");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Креативы", href: "/creatives" },
|
||||
{ label: "Загрузка..." },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !creative) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Креативы", href: "/creatives" },
|
||||
{ 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="/creatives">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Вернуться к креативам
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const previewText = creative.text.replace(
|
||||
"{link}",
|
||||
"https://t.me/+InviteLinkExample"
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: "/" },
|
||||
{ label: "Креативы", href: "/creatives" },
|
||||
{ label: creative.name },
|
||||
]}
|
||||
/>
|
||||
<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">
|
||||
<Folder className="h-6 w-6" />
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{creative.name}
|
||||
</h1>
|
||||
{creative.is_archived && <Badge variant="secondary">Архив</Badge>}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Целевой канал: {creative.target_channel.title}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleArchive}>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
{creative.is_archived ? "Разархивировать" : "Архивировать"}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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(creative.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(creative.total_subscriptions)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Всего</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Средний CPF</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
₽{formatMetric(creative.avg_cpf)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
По всем закупам
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Текст креатива</CardTitle>
|
||||
<CardDescription>Шаблон с переменной {"{link}"}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<pre className="text-sm whitespace-pre-wrap font-sans">
|
||||
{creative.text}
|
||||
</pre>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Предпросмотр</CardTitle>
|
||||
<CardDescription>
|
||||
С подставленной пригласительной ссылкой
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<p className="text-sm whitespace-pre-wrap">{previewText}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Закупы с этим креативом</CardTitle>
|
||||
<CardDescription>История использования креатива</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{creative.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>
|
||||
{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>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
249
app/(dashboard)/creatives/new/page.tsx
Normal file
249
app/(dashboard)/creatives/new/page.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Create Creative Page
|
||||
// ============================================================================
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { creativesApi, channelsApi } from "@/lib/api";
|
||||
import { AlertCircle, Loader2, ArrowLeft, Info } from "lucide-react";
|
||||
import type { TargetChannel } from "@/lib/types/api";
|
||||
|
||||
export default function CreateCreativePage() {
|
||||
const router = useRouter();
|
||||
const [channels, setChannels] = useState<TargetChannel[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
text: "",
|
||||
target_channel_id: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadChannels();
|
||||
}, []);
|
||||
|
||||
const loadChannels = async () => {
|
||||
try {
|
||||
const response = await channelsApi.list({ is_active: true });
|
||||
setChannels(response.data);
|
||||
} catch (err) {
|
||||
console.error("Error loading channels:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
// Validation
|
||||
if (!formData.name.trim()) {
|
||||
setError("Введите название креатива");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.text.trim()) {
|
||||
setError("Введите текст креатива");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.text.includes("{link}")) {
|
||||
setError("Текст должен содержать переменную {link} для вставки ссылки");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.target_channel_id) {
|
||||
setError("Выберите целевой канал");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await creativesApi.create(formData);
|
||||
router.push("/creatives");
|
||||
} catch (err: any) {
|
||||
setError(err?.error?.message || "Ошибка создания креатива");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const previewText = formData.text.replace(
|
||||
"{link}",
|
||||
"https://t.me/+InviteLinkExample"
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: "/" },
|
||||
{ label: "Креативы", href: "/creatives" },
|
||||
{ label: "Создание" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-4xl mx-auto w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Создать креатив
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Новый шаблон рекламного сообщения
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<a href="/creatives">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Назад
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Основная информация</CardTitle>
|
||||
<CardDescription>
|
||||
Название и целевой канал креатива
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Название креатива *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
placeholder='Например: Креатив "Присоединяйся"'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="target_channel_id">Целевой канал *</Label>
|
||||
<Select
|
||||
value={formData.target_channel_id}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, target_channel_id: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите канал" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{channels.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.id}>
|
||||
{channel.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Текст сообщения</CardTitle>
|
||||
<CardDescription>
|
||||
Используйте переменную {"{link}"} для вставки ссылки
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="text">Текст креатива *</Label>
|
||||
<Textarea
|
||||
id="text"
|
||||
value={formData.text}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, text: e.target.value })
|
||||
}
|
||||
placeholder="🔥 Присоединяйся к нашему сообществу! Узнавай первым о новых возможностях. 👉 {link}"
|
||||
rows={8}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
<strong>Обязательно</strong> включите переменную {"{link}"} в
|
||||
текст. На её место будет подставлена пригласительная ссылка в
|
||||
целевой канал.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{formData.text && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Предпросмотр</CardTitle>
|
||||
<CardDescription>
|
||||
Так будет выглядеть сообщение для размещения
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<p className="text-sm whitespace-pre-wrap">{previewText}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push("/creatives")}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Создание...
|
||||
</>
|
||||
) : (
|
||||
"Создать креатив"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
255
app/(dashboard)/creatives/page.tsx
Normal file
255
app/(dashboard)/creatives/page.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Creatives List Page
|
||||
// ============================================================================
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { creativesApi } from "@/lib/api";
|
||||
import { formatNumber, formatMetric, truncate } from "@/lib/utils/format";
|
||||
import {
|
||||
Folder,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Plus,
|
||||
Eye,
|
||||
Pencil,
|
||||
Archive,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { Creative } from "@/lib/types/api";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
export default function CreativesPage() {
|
||||
const [creatives, setCreatives] = useState<Creative[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"all" | "active" | "archived">(
|
||||
"active"
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadCreatives();
|
||||
}, []);
|
||||
|
||||
const loadCreatives = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await creativesApi.list();
|
||||
setCreatives(response.data);
|
||||
} catch (err: any) {
|
||||
setError(err?.error?.message || "Ошибка загрузки креативов");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (id: string, isArchived: boolean) => {
|
||||
try {
|
||||
await creativesApi.update(id, { is_archived: !isArchived });
|
||||
loadCreatives();
|
||||
} catch (err: any) {
|
||||
alert(err?.error?.message || "Ошибка обновления креатива");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`Удалить креатив "${name}"?`)) return;
|
||||
|
||||
try {
|
||||
await creativesApi.delete(id);
|
||||
loadCreatives();
|
||||
} catch (err: any) {
|
||||
alert(err?.error?.message || "Ошибка удаления креатива");
|
||||
}
|
||||
};
|
||||
|
||||
const filteredCreatives = creatives.filter((creative) => {
|
||||
if (activeTab === "active") return !creative.is_archived;
|
||||
if (activeTab === "archived") return creative.is_archived;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[{ label: "Главная", href: "/" }, { label: "Креативы" }]}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Креативы</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Шаблоны рекламных сообщений
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/creatives/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Создать креатив
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as any)}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="active">
|
||||
Активные ({creatives.filter((c) => !c.is_archived).length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="archived">
|
||||
Архив ({creatives.filter((c) => c.is_archived).length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="all">Все ({creatives.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={activeTab} className="mt-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredCreatives.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center p-12">
|
||||
<Folder className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-lg font-medium text-muted-foreground">
|
||||
{activeTab === "all" && "Нет креативов"}
|
||||
{activeTab === "active" && "Нет активных креативов"}
|
||||
{activeTab === "archived" && "Нет архивных креативов"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Создайте первый креатив для рекламных сообщений
|
||||
</p>
|
||||
{activeTab === "active" && (
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/creatives/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Создать креатив
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredCreatives.map((creative) => (
|
||||
<Card
|
||||
key={creative.id}
|
||||
className="hover:shadow-md transition-shadow"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<CardTitle className="truncate text-base">
|
||||
{creative.name}
|
||||
</CardTitle>
|
||||
{creative.is_archived && (
|
||||
<Badge variant="secondary">Архив</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="text-xs">
|
||||
{creative.target_channel.title}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Folder className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="rounded-lg border bg-muted/50 p-3">
|
||||
<p className="text-sm whitespace-pre-wrap line-clamp-4">
|
||||
{creative.text}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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(creative.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(creative.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(creative.avg_cpf)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
CPF
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
<Link href={`/creatives/${creative.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Открыть
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleArchive(creative.id, creative.is_archived)
|
||||
}
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleDelete(creative.id, creative.name)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
320
app/(dashboard)/external-channels/[id]/page.tsx
Normal file
320
app/(dashboard)/external-channels/[id]/page.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// External Channel Detail Page
|
||||
// ============================================================================
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { use } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { externalChannelsApi } from "@/lib/api";
|
||||
import {
|
||||
formatNumber,
|
||||
formatMetric,
|
||||
formatCurrency,
|
||||
formatUsername,
|
||||
formatDate,
|
||||
formatCompactNumber,
|
||||
} from "@/lib/utils/format";
|
||||
import {
|
||||
ExternalLink as ExternalLinkIcon,
|
||||
Users,
|
||||
TrendingUp,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { ExternalChannelDetail } from "@/lib/types/api";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function ExternalChannelDetailPage({ params }: PageProps) {
|
||||
const resolvedParams = use(params);
|
||||
const router = useRouter();
|
||||
const [channel, setChannel] = useState<ExternalChannelDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadChannel = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await externalChannelsApi.get(resolvedParams.id);
|
||||
setChannel(data);
|
||||
} catch (err: any) {
|
||||
setError(err?.error?.message || "Ошибка загрузки канала");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadChannel();
|
||||
}, [resolvedParams.id]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!channel) return;
|
||||
if (!confirm(`Удалить канал "${channel.title}"?`)) return;
|
||||
|
||||
try {
|
||||
await externalChannelsApi.delete(channel.id);
|
||||
router.push("/external-channels");
|
||||
} catch (err: any) {
|
||||
alert(err?.error?.message || "Ошибка удаления канала");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Внешние каналы", href: "/external-channels" },
|
||||
{ label: "Загрузка..." },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !channel) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Внешние каналы", href: "/external-channels" },
|
||||
{ label: "Ошибка" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error || "Канал не найден"}</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/external-channels">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Вернуться к каналам
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: "/" },
|
||||
{ label: "Внешние каналы", href: "/external-channels" },
|
||||
{ label: channel.title },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ExternalLinkIcon className="h-6 w-6" />
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{channel.title}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
{channel.username ? (
|
||||
<a
|
||||
href={channel.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline flex items-center gap-1"
|
||||
>
|
||||
{formatUsername(channel.username)}
|
||||
<ExternalLinkIcon className="h-3 w-3" />
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={channel.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline flex items-center gap-1"
|
||||
>
|
||||
{channel.link}
|
||||
<ExternalLinkIcon className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
<span>•</span>
|
||||
<span>Добавлен {formatDate(channel.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{channel.description && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Описание</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">{channel.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Подписчиков</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{channel.subscribers_count
|
||||
? formatCompactNumber(channel.subscribers_count)
|
||||
: "—"}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">В канале</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Всего закупов
|
||||
</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(channel.total_purchases)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
В этом канале
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Средний CPF</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
₽{formatMetric(channel.avg_cpf)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
По всем закупам
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Средний CPM</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
₽{formatMetric(channel.avg_cpm)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Стоимость 1000 просмотров
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>История закупов</CardTitle>
|
||||
<CardDescription>
|
||||
Все рекламные размещения в этом канале
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{channel.purchases.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
Закупов в этом канале пока нет
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Целевой канал</TableHead>
|
||||
<TableHead>Дата</TableHead>
|
||||
<TableHead className="text-right">Стоимость</TableHead>
|
||||
<TableHead className="text-right">Подписчики</TableHead>
|
||||
<TableHead className="text-right">CPF</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{channel.purchases.map((purchase) => (
|
||||
<TableRow key={purchase.id}>
|
||||
<TableCell className="font-medium">
|
||||
{purchase.target_channel.title}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDate(
|
||||
purchase.actual_date || purchase.scheduled_date
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(purchase.cost)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(purchase.subscriptions_count)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
₽{formatMetric(purchase.cpf)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/purchases/${purchase.id}`}>
|
||||
Открыть
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
338
app/(dashboard)/external-channels/import/page.tsx
Normal file
338
app/(dashboard)/external-channels/import/page.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// External Channels Import Page
|
||||
// ============================================================================
|
||||
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { externalChannelsApi } from "@/lib/api";
|
||||
import {
|
||||
FileUp,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Download,
|
||||
ArrowLeft,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { ExternalChannelImportResponse } from "@/lib/types/api";
|
||||
|
||||
export default function ImportExternalChannelsPage() {
|
||||
const router = useRouter();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [result, setResult] = useState<ExternalChannelImportResponse | null>(
|
||||
null
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
const droppedFile = e.dataTransfer.files[0];
|
||||
if (droppedFile) {
|
||||
handleFileSelect(droppedFile);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (selectedFile: File) => {
|
||||
// Проверка типа файла
|
||||
const validTypes = [
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"text/csv",
|
||||
];
|
||||
|
||||
if (
|
||||
!validTypes.includes(selectedFile.type) &&
|
||||
!selectedFile.name.match(/\.(xlsx|xls|csv)$/i)
|
||||
) {
|
||||
setError("Пожалуйста, выберите файл Excel (.xlsx, .xls) или CSV");
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(selectedFile);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
};
|
||||
|
||||
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
if (selectedFile) {
|
||||
handleFileSelect(selectedFile);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
|
||||
// Для примера используем первый целевой канал (tc1)
|
||||
// В реальном приложении нужно дать пользователю выбрать
|
||||
const importResult = await externalChannelsApi.import(file, "tc1");
|
||||
setResult(importResult);
|
||||
setFile(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.error?.message || "Ошибка импорта файла");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFile(null);
|
||||
setResult(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleGoToChannels = () => {
|
||||
router.push("/external-channels");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: "/" },
|
||||
{ label: "Внешние каналы", href: "/external-channels" },
|
||||
{ label: "Импорт" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-4xl mx-auto w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Импорт внешних каналов
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Загрузите файл Excel с данными из Tgstat
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/external-channels">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Назад
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Формат файла</CardTitle>
|
||||
<CardDescription>
|
||||
Требования к структуре Excel файла
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 text-sm">
|
||||
<p>Файл должен содержать следующие колонки:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||
<li>
|
||||
<strong>Название</strong> - название канала
|
||||
</li>
|
||||
<li>
|
||||
<strong>Username</strong> - username канала (с @ или без)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Ссылка</strong> - ссылка на канал (t.me/...)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Подписчики</strong> - количество подписчиков (число)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Описание</strong> - краткое описание (опционально)
|
||||
</li>
|
||||
</ul>
|
||||
<div className="pt-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Скачать шаблон
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!result ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Загрузка файла</CardTitle>
|
||||
<CardDescription>
|
||||
Перетащите файл или выберите его
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`
|
||||
border-2 border-dashed rounded-lg p-12 text-center transition-colors
|
||||
${
|
||||
isDragging
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-muted-foreground/25"
|
||||
}
|
||||
${file ? "bg-muted/50" : ""}
|
||||
`}
|
||||
>
|
||||
{file ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<FileUp className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{file.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(file.size / 1024).toFixed(2)} KB
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Выбрать другой файл
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-center">
|
||||
<FileUp className="h-12 w-12 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-medium">
|
||||
Перетащите файл сюда
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
или нажмите кнопку ниже
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="file-upload">
|
||||
<Button asChild variant="outline">
|
||||
<span>
|
||||
<FileUp className="mr-2 h-4 w-4" />
|
||||
Выбрать файл
|
||||
</span>
|
||||
</Button>
|
||||
</label>
|
||||
<input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={handleFileInputChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{file && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={handleUpload} disabled={isUploading}>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Импорт...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileUp className="mr-2 h-4 w-4" />
|
||||
Импортировать
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-6 w-6 text-green-500" />
|
||||
<CardTitle>Импорт завершен</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Результаты обработки файла</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-green-50 dark:bg-green-950/20">
|
||||
<span className="text-sm font-medium">
|
||||
Импортировано каналов
|
||||
</span>
|
||||
<Badge variant="default">{result.imported}</Badge>
|
||||
</div>
|
||||
{result.skipped > 0 && (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-yellow-50 dark:bg-yellow-950/20">
|
||||
<span className="text-sm font-medium">Пропущено</span>
|
||||
<Badge variant="secondary">{result.skipped}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result.errors.length > 0 && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<p className="font-medium mb-2">
|
||||
Обнаружены ошибки при импорте:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm">
|
||||
{result.errors.map((error, index) => (
|
||||
<li key={index}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
Импортировать еще
|
||||
</Button>
|
||||
<Button onClick={handleGoToChannels}>Перейти к каналам</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
406
app/(dashboard)/external-channels/page.tsx
Normal file
406
app/(dashboard)/external-channels/page.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// External Channels List Page
|
||||
// ============================================================================
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { externalChannelsApi } from "@/lib/api";
|
||||
import {
|
||||
formatNumber,
|
||||
formatMetric,
|
||||
formatUsername,
|
||||
formatCompactNumber,
|
||||
} from "@/lib/utils/format";
|
||||
import {
|
||||
ExternalLink as ExternalLinkIcon,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Plus,
|
||||
Search,
|
||||
FileUp,
|
||||
Eye,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import type { ExternalChannel } from "@/lib/types/api";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export default function ExternalChannelsPage() {
|
||||
const [channels, setChannels] = useState<ExternalChannel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
title: "",
|
||||
username: "",
|
||||
link: "",
|
||||
subscribers_count: "",
|
||||
description: "",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadChannels();
|
||||
}, []);
|
||||
|
||||
const loadChannels = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await externalChannelsApi.list();
|
||||
setChannels(response.data);
|
||||
} catch (err: any) {
|
||||
setError(err?.error?.message || "Ошибка загрузки каналов");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
setIsCreating(true);
|
||||
await externalChannelsApi.create({
|
||||
title: formData.title,
|
||||
username: formData.username || undefined,
|
||||
link: formData.link,
|
||||
subscribers_count: formData.subscribers_count
|
||||
? parseInt(formData.subscribers_count)
|
||||
: undefined,
|
||||
description: formData.description || undefined,
|
||||
target_channel_ids: [], // По умолчанию не привязываем
|
||||
});
|
||||
setIsCreateDialogOpen(false);
|
||||
setFormData({
|
||||
title: "",
|
||||
username: "",
|
||||
link: "",
|
||||
subscribers_count: "",
|
||||
description: "",
|
||||
});
|
||||
loadChannels();
|
||||
} catch (err: any) {
|
||||
alert(err?.error?.message || "Ошибка создания канала");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, title: string) => {
|
||||
if (!confirm(`Удалить канал "${title}"?`)) return;
|
||||
|
||||
try {
|
||||
await externalChannelsApi.delete(id);
|
||||
loadChannels();
|
||||
} catch (err: any) {
|
||||
alert(err?.error?.message || "Ошибка удаления канала");
|
||||
}
|
||||
};
|
||||
|
||||
const filteredChannels = channels.filter(
|
||||
(channel) =>
|
||||
channel.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
channel.username?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: "/" },
|
||||
{ label: "Внешние каналы" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Внешние каналы
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Каналы, в которых покупаете рекламу
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/external-channels/import">
|
||||
<FileUp className="mr-2 h-4 w-4" />
|
||||
Импорт из Excel
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Dialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={setIsCreateDialogOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Добавить канал
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Добавить внешний канал</DialogTitle>
|
||||
<DialogDescription>
|
||||
Добавьте канал, в котором планируете покупать рекламу
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">Название *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, title: e.target.value })
|
||||
}
|
||||
placeholder="Название канала"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="link">Ссылка *</Label>
|
||||
<Input
|
||||
id="link"
|
||||
value={formData.link}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, link: e.target.value })
|
||||
}
|
||||
placeholder="https://t.me/channel_name"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={formData.username}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, username: e.target.value })
|
||||
}
|
||||
placeholder="@channel_name"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="subscribers">Количество подписчиков</Label>
|
||||
<Input
|
||||
id="subscribers"
|
||||
type="number"
|
||||
value={formData.subscribers_count}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
subscribers_count: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="50000"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Описание</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Краткое описание канала"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsCreateDialogOpen(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={isCreating}>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Создание...
|
||||
</>
|
||||
) : (
|
||||
"Создать"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Поиск по названию или username..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
Всего: {formatNumber(filteredChannels.length)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredChannels.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center p-12">
|
||||
<ExternalLinkIcon className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-lg font-medium text-muted-foreground">
|
||||
{searchQuery ? "Каналы не найдены" : "Нет внешних каналов"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{searchQuery
|
||||
? "Попробуйте изменить поисковый запрос"
|
||||
: "Добавьте каналы вручную или импортируйте из Excel"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredChannels.map((channel) => (
|
||||
<Card
|
||||
key={channel.id}
|
||||
className="hover:shadow-md transition-shadow"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="truncate flex items-center gap-2">
|
||||
<ExternalLinkIcon className="h-4 w-4 shrink-0" />
|
||||
{channel.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="truncate mt-1">
|
||||
{channel.username ? (
|
||||
<a
|
||||
href={channel.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{formatUsername(channel.username)}
|
||||
</a>
|
||||
) : (
|
||||
<a
|
||||
href={channel.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline text-xs"
|
||||
>
|
||||
{channel.link}
|
||||
</a>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{channel.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{channel.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div className="rounded-lg border bg-muted/50 p-2">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Users className="h-3 w-3 text-muted-foreground" />
|
||||
<div className="text-sm font-medium">
|
||||
{channel.subscribers_count
|
||||
? formatCompactNumber(channel.subscribers_count)
|
||||
: "—"}
|
||||
</div>
|
||||
</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_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">
|
||||
₽{formatMetric(channel.avg_cpf)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">CPF</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
asChild
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
>
|
||||
<Link href={`/external-channels/${channel.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Подробнее
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(channel.id, channel.title)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
22
app/(dashboard)/layout.tsx
Normal file
22
app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
// ============================================================================
|
||||
// Dashboard Layout
|
||||
// ============================================================================
|
||||
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { ProtectedRoute } from "@/components/auth/protected-route";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ProtectedRoute>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>{children}</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
186
app/(dashboard)/page.tsx
Normal file
186
app/(dashboard)/page.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Dashboard Home Page
|
||||
// ============================================================================
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { analyticsApi } from "@/lib/api";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatNumber,
|
||||
formatMetric,
|
||||
formatPercent,
|
||||
} from "@/lib/utils/format";
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
Users,
|
||||
ShoppingCart,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import type { AnalyticsOverview } from "@/lib/types/api";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [overview, setOverview] = useState<AnalyticsOverview | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadOverview = async () => {
|
||||
try {
|
||||
const data = await analyticsApi.overview();
|
||||
setOverview(data);
|
||||
} catch (error) {
|
||||
console.error("Error loading overview:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadOverview();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader breadcrumbs={[{ label: "Главная" }]} />
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg: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>
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(overview?.totalPurchases)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatNumber(overview?.purchasesChange || 0, true)} за
|
||||
прошлый месяц
|
||||
</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>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(overview?.totalFollowers)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatNumber(overview?.followersChange || 0, true)} за
|
||||
прошлый месяц
|
||||
</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>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
₽{formatMetric(overview?.averageCpf)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatPercent(overview?.cpfChange || 0, true)} от прошлого
|
||||
месяца
|
||||
</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>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
₽{formatMetric(overview?.averageCpm)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatPercent(overview?.cpmChange || 0, true)} от прошлого
|
||||
месяца
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-1">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Быстрый старт</CardTitle>
|
||||
<CardDescription>Начните с основных действий</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<a
|
||||
href="/purchases/new"
|
||||
className="block text-sm text-primary hover:underline"
|
||||
>
|
||||
→ Создать новый закуп
|
||||
</a>
|
||||
<a
|
||||
href="/creatives/new"
|
||||
className="block text-sm text-primary hover:underline"
|
||||
>
|
||||
→ Создать креатив
|
||||
</a>
|
||||
<a
|
||||
href="/external-channels/import"
|
||||
className="block text-sm text-primary hover:underline"
|
||||
>
|
||||
→ Импортировать каналы
|
||||
</a>
|
||||
<a
|
||||
href="/analytics"
|
||||
className="block text-sm text-primary hover:underline"
|
||||
>
|
||||
→ Посмотреть аналитику
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
500
app/(dashboard)/purchases/[id]/page.tsx
Normal file
500
app/(dashboard)/purchases/[id]/page.tsx
Normal file
@@ -0,0 +1,500 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Purchase Detail Page
|
||||
// ============================================================================
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { use } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { purchasesApi } from "@/lib/api";
|
||||
import {
|
||||
formatNumber,
|
||||
formatMetric,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
formatPercent,
|
||||
formatUsername,
|
||||
} from "@/lib/utils/format";
|
||||
import {
|
||||
ShoppingCart,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Archive,
|
||||
Trash2,
|
||||
BarChart3,
|
||||
Users,
|
||||
TrendingUp,
|
||||
Eye,
|
||||
Copy,
|
||||
ExternalLink as ExternalLinkIcon,
|
||||
Check,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
import type { PurchaseDetail } from "@/lib/types/api";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function PurchaseDetailPage({ params }: PageProps) {
|
||||
const resolvedParams = use(params);
|
||||
const router = useRouter();
|
||||
const [purchase, setPurchase] = useState<PurchaseDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadPurchase = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await purchasesApi.get(resolvedParams.id);
|
||||
setPurchase(data);
|
||||
} catch (err: any) {
|
||||
setError(err?.error?.message || "Ошибка загрузки закупа");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadPurchase();
|
||||
}, [resolvedParams.id]);
|
||||
|
||||
const handleArchive = async () => {
|
||||
if (!purchase) return;
|
||||
|
||||
try {
|
||||
await purchasesApi.update(purchase.id, {
|
||||
is_archived: !purchase.is_archived,
|
||||
});
|
||||
setPurchase({ ...purchase, is_archived: !purchase.is_archived });
|
||||
} catch (err: any) {
|
||||
alert(err?.error?.message || "Ошибка обновления закупа");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!purchase) return;
|
||||
if (!confirm("Удалить закуп?")) return;
|
||||
|
||||
try {
|
||||
await purchasesApi.delete(purchase.id);
|
||||
router.push("/purchases");
|
||||
} catch (err: any) {
|
||||
alert(err?.error?.message || "Ошибка удаления закупа");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
if (!purchase) return;
|
||||
navigator.clipboard.writeText(purchase.invite_link);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Закупы", href: "/purchases" },
|
||||
{ label: "Загрузка..." },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !purchase) {
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Закупы", href: "/purchases" },
|
||||
{ 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="/purchases">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Вернуться к закупам
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const messageText = purchase.creative.text.replace(
|
||||
"{link}",
|
||||
purchase.invite_link
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: "/" },
|
||||
{ label: "Закупы", href: "/purchases" },
|
||||
{ label: `Закуп #${purchase.id.slice(0, 8)}` },
|
||||
]}
|
||||
/>
|
||||
<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">
|
||||
<ShoppingCart className="h-6 w-6" />
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{purchase.external_channel.title}
|
||||
</h1>
|
||||
{purchase.is_archived ? (
|
||||
<Badge variant="secondary">Архив</Badge>
|
||||
) : purchase.actual_date ? (
|
||||
<Badge variant="default">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Завершено
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
Запланировано
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
|
||||
<span>
|
||||
Целевой канал:{" "}
|
||||
<Link
|
||||
href={`/channels/${purchase.target_channel.id}`}
|
||||
className="hover:underline font-medium"
|
||||
>
|
||||
{purchase.target_channel.title}
|
||||
</Link>
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
Креатив:{" "}
|
||||
<Link
|
||||
href={`/creatives/${purchase.creative.id}`}
|
||||
className="hover:underline font-medium"
|
||||
>
|
||||
{purchase.creative.name}
|
||||
</Link>
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
Дата размещения:{" "}
|
||||
{formatDate(purchase.actual_date || purchase.scheduled_date)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleArchive}>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
{purchase.is_archived ? "Разархивировать" : "Архивировать"}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Стоимость</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(purchase.cost)}
|
||||
</div>
|
||||
</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(purchase.subscriptions_count)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Просмотры</CardTitle>
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{purchase.views_count
|
||||
? formatNumber(purchase.views_count)
|
||||
: "—"}
|
||||
</div>
|
||||
</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">
|
||||
{purchase.cpf ? `₽${formatMetric(purchase.cpf)}` : "—"}
|
||||
</div>
|
||||
</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">
|
||||
{purchase.conversion_rate
|
||||
? formatPercent(purchase.conversion_rate)
|
||||
: "—"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Пригласительная ссылка</CardTitle>
|
||||
<CardDescription>
|
||||
Используется для отслеживания подписок
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<code className="flex-1 p-2 bg-muted rounded text-sm break-all">
|
||||
{purchase.invite_link}
|
||||
</code>
|
||||
<Button onClick={handleCopyLink} variant="outline" size="icon">
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{purchase.post_link && (
|
||||
<div className="pt-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
Ссылка на рекламный пост:
|
||||
</Label>
|
||||
<a
|
||||
href={purchase.post_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm hover:underline mt-1"
|
||||
>
|
||||
{purchase.post_link}
|
||||
<ExternalLinkIcon className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Рекламное сообщение</CardTitle>
|
||||
<CardDescription>Текст для размещения</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<p className="text-sm whitespace-pre-wrap">{messageText}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{purchase.comment && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Комментарий</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{purchase.comment}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="subscriptions" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="subscriptions">
|
||||
Подписки ({purchase.subscriptions.length})
|
||||
</TabsTrigger>
|
||||
{purchase.views_history && purchase.views_history.length > 0 && (
|
||||
<TabsTrigger value="views">
|
||||
История просмотров ({purchase.views_history.length})
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="subscriptions" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Подписки</CardTitle>
|
||||
<CardDescription>
|
||||
Пользователи, перешедшие по ссылке
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{purchase.subscriptions.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
Подписок пока нет
|
||||
</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Пользователь</TableHead>
|
||||
<TableHead>Username</TableHead>
|
||||
<TableHead>Дата подписки</TableHead>
|
||||
<TableHead>Статус</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{purchase.subscriptions.map((subscription) => (
|
||||
<TableRow key={subscription.id}>
|
||||
<TableCell className="font-medium">
|
||||
{subscription.user_first_name}{" "}
|
||||
{subscription.user_last_name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{subscription.user_username
|
||||
? formatUsername(subscription.user_username)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatDate(subscription.subscribed_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{subscription.is_active ? (
|
||||
<Badge variant="default">Активен</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Отписался</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{purchase.views_history && purchase.views_history.length > 0 && (
|
||||
<TabsContent value="views" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>История просмотров</CardTitle>
|
||||
<CardDescription>
|
||||
Динамика просмотров рекламного поста
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Дата</TableHead>
|
||||
<TableHead className="text-right">Просмотры</TableHead>
|
||||
<TableHead className="text-right">Прирост</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{purchase.views_history.map((item, index) => {
|
||||
const prevViews =
|
||||
index > 0
|
||||
? purchase.views_history![index - 1].views
|
||||
: 0;
|
||||
const growth = item.views - prevViews;
|
||||
|
||||
return (
|
||||
<TableRow key={item.fetched_at}>
|
||||
<TableCell>{formatDate(item.fetched_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatNumber(item.views)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{index > 0 && (
|
||||
<span
|
||||
className={
|
||||
growth > 0
|
||||
? "text-green-600"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
+{formatNumber(growth)}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Label({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
503
app/(dashboard)/purchases/new/page.tsx
Normal file
503
app/(dashboard)/purchases/new/page.tsx
Normal file
@@ -0,0 +1,503 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Create Purchase Page
|
||||
// ============================================================================
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
purchasesApi,
|
||||
channelsApi,
|
||||
externalChannelsApi,
|
||||
creativesApi,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
Info,
|
||||
Copy,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import type { TargetChannel, ExternalChannel, Creative } from "@/lib/types/api";
|
||||
|
||||
export default function CreatePurchasePage() {
|
||||
const router = useRouter();
|
||||
const [channels, setChannels] = useState<TargetChannel[]>([]);
|
||||
const [externalChannels, setExternalChannels] = useState<ExternalChannel[]>(
|
||||
[]
|
||||
);
|
||||
const [creatives, setCreatives] = useState<Creative[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [inviteLink, setInviteLink] = useState<string>("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
target_channel_id: "",
|
||||
external_channel_id: "",
|
||||
creative_id: "",
|
||||
scheduled_date: "",
|
||||
cost: "",
|
||||
comment: "",
|
||||
post_link: "",
|
||||
invite_link_type: "public" as "public" | "private",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [channelsRes, externalChannelsRes, creativesRes] =
|
||||
await Promise.all([
|
||||
channelsApi.list({ is_active: true }),
|
||||
externalChannelsApi.list(),
|
||||
creativesApi.list(),
|
||||
]);
|
||||
setChannels(channelsRes.data);
|
||||
setExternalChannels(externalChannelsRes.data);
|
||||
setCreatives(creativesRes.data.filter((c) => !c.is_archived));
|
||||
} catch (err) {
|
||||
console.error("Error loading data:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Фильтр креативов по выбранному целевому каналу
|
||||
const filteredCreatives = formData.target_channel_id
|
||||
? creatives.filter(
|
||||
(c) => c.target_channel_id === formData.target_channel_id
|
||||
)
|
||||
: creatives;
|
||||
|
||||
const selectedCreative = creatives.find((c) => c.id === formData.creative_id);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
// Validation
|
||||
if (!formData.target_channel_id) {
|
||||
setError("Выберите целевой канал");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.external_channel_id) {
|
||||
setError("Выберите внешний канал");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.creative_id) {
|
||||
setError("Выберите креатив");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.scheduled_date) {
|
||||
setError("Укажите дату размещения");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.cost || parseFloat(formData.cost) <= 0) {
|
||||
setError("Укажите корректную стоимость");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await purchasesApi.create({
|
||||
target_channel_id: formData.target_channel_id,
|
||||
external_channel_id: formData.external_channel_id,
|
||||
creative_id: formData.creative_id,
|
||||
scheduled_date: formData.scheduled_date,
|
||||
cost: parseFloat(formData.cost),
|
||||
comment: formData.comment || undefined,
|
||||
post_link: formData.post_link || undefined,
|
||||
invite_link_type: formData.invite_link_type,
|
||||
});
|
||||
|
||||
// Показываем сгенерированную ссылку
|
||||
setInviteLink(result.invite_link);
|
||||
} catch (err: any) {
|
||||
setError(err?.error?.message || "Ошибка создания закупа");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard.writeText(inviteLink);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleCopyMessage = () => {
|
||||
if (!selectedCreative) return;
|
||||
const message = selectedCreative.text.replace("{link}", inviteLink);
|
||||
navigator.clipboard.writeText(message);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (inviteLink) {
|
||||
const messageText = selectedCreative
|
||||
? selectedCreative.text.replace("{link}", inviteLink)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: "/" },
|
||||
{ label: "Закупы", href: "/purchases" },
|
||||
{ label: "Создание" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-4xl mx-auto w-full">
|
||||
<Card className="border-green-500">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-green-600">
|
||||
<Check className="h-5 w-5" />
|
||||
Закуп успешно создан!
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Используйте ссылку и готовое сообщение для размещения
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Пригласительная ссылка</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={inviteLink} readOnly className="font-mono" />
|
||||
<Button onClick={handleCopyLink} variant="outline">
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Готовое сообщение для размещения</Label>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
value={messageText}
|
||||
readOnly
|
||||
rows={8}
|
||||
className="pr-12"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCopyMessage}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Скопируйте готовое сообщение и разместите его во внешнем
|
||||
канале. Все переходы по ссылке будут автоматически
|
||||
отслеживаться.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button asChild>
|
||||
<Link href="/purchases">Перейти к закупам</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setInviteLink("");
|
||||
setFormData({
|
||||
target_channel_id: "",
|
||||
external_channel_id: "",
|
||||
creative_id: "",
|
||||
scheduled_date: "",
|
||||
cost: "",
|
||||
comment: "",
|
||||
post_link: "",
|
||||
invite_link_type: "public",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Создать еще
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[
|
||||
{ label: "Главная", href: "/" },
|
||||
{ label: "Закупы", href: "/purchases" },
|
||||
{ label: "Создание" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4 max-w-4xl mx-auto w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Создать закуп</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Новое рекламное размещение
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/purchases">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Назад
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Основная информация</CardTitle>
|
||||
<CardDescription>
|
||||
Выберите каналы и креатив для размещения
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="target_channel_id">Целевой канал *</Label>
|
||||
<Select
|
||||
value={formData.target_channel_id}
|
||||
onValueChange={(value) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
target_channel_id: value,
|
||||
creative_id: "", // Сбросить креатив при смене канала
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите канал" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{channels.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.id}>
|
||||
{channel.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="external_channel_id">Внешний канал *</Label>
|
||||
<Select
|
||||
value={formData.external_channel_id}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, external_channel_id: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите канал" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{externalChannels.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.id}>
|
||||
{channel.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="creative_id">Креатив *</Label>
|
||||
<Select
|
||||
value={formData.creative_id}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, creative_id: value })
|
||||
}
|
||||
disabled={!formData.target_channel_id}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
formData.target_channel_id
|
||||
? "Выберите креатив"
|
||||
: "Сначала выберите целевой канал"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredCreatives.map((creative) => (
|
||||
<SelectItem key={creative.id} value={creative.id}>
|
||||
{creative.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Детали размещения</CardTitle>
|
||||
<CardDescription>
|
||||
Стоимость, дата и параметры ссылки
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scheduled_date">Дата размещения *</Label>
|
||||
<Input
|
||||
id="scheduled_date"
|
||||
type="date"
|
||||
value={formData.scheduled_date}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
scheduled_date: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cost">Стоимость (₽) *</Label>
|
||||
<Input
|
||||
id="cost"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={formData.cost}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, cost: e.target.value })
|
||||
}
|
||||
placeholder="1000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="post_link">Ссылка на рекламное сообщение</Label>
|
||||
<Input
|
||||
id="post_link"
|
||||
type="url"
|
||||
value={formData.post_link}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, post_link: e.target.value })
|
||||
}
|
||||
placeholder="https://t.me/channel/123"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Для автоматического отслеживания просмотров
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Тип пригласительной ссылки *</Label>
|
||||
<RadioGroup
|
||||
value={formData.invite_link_type}
|
||||
onValueChange={(value: "public" | "private") =>
|
||||
setFormData({ ...formData, invite_link_type: value })
|
||||
}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="public" id="public" />
|
||||
<Label htmlFor="public" className="font-normal">
|
||||
Открытая (пользователь сразу вступает в канал)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="private" id="private" />
|
||||
<Label htmlFor="private" className="font-normal">
|
||||
С одобрением (запрос на вступление через бота)
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="comment">Комментарий</Label>
|
||||
<Textarea
|
||||
id="comment"
|
||||
value={formData.comment}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, comment: e.target.value })
|
||||
}
|
||||
placeholder="Дополнительные заметки о закупе"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push("/purchases")}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Создание...
|
||||
</>
|
||||
) : (
|
||||
"Создать закуп"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
381
app/(dashboard)/purchases/page.tsx
Normal file
381
app/(dashboard)/purchases/page.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Purchases List Page
|
||||
// ============================================================================
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { DashboardHeader } from "@/components/layout/dashboard-header";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { purchasesApi, channelsApi } from "@/lib/api";
|
||||
import {
|
||||
formatNumber,
|
||||
formatMetric,
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
formatPercent,
|
||||
} from "@/lib/utils/format";
|
||||
import {
|
||||
ShoppingCart,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Eye,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Archive,
|
||||
} from "lucide-react";
|
||||
import type { Purchase, TargetChannel } from "@/lib/types/api";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
export default function PurchasesPage() {
|
||||
const [purchases, setPurchases] = useState<Purchase[]>([]);
|
||||
const [channels, setChannels] = useState<TargetChannel[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filterChannel, setFilterChannel] = useState<string>("all");
|
||||
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [purchasesRes, channelsRes] = await Promise.all([
|
||||
purchasesApi.list(),
|
||||
channelsApi.list({}),
|
||||
]);
|
||||
setPurchases(purchasesRes.data);
|
||||
setChannels(channelsRes.data);
|
||||
} catch (err: any) {
|
||||
setError(err?.error?.message || "Ошибка загрузки данных");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPurchases = purchases.filter((purchase) => {
|
||||
// Поиск
|
||||
const matchesSearch =
|
||||
purchase.external_channel.title
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()) ||
|
||||
purchase.target_channel.title
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()) ||
|
||||
purchase.creative.name.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
// Фильтр по каналу
|
||||
const matchesChannel =
|
||||
filterChannel === "all" || purchase.target_channel.id === filterChannel;
|
||||
|
||||
// Фильтр по статусу
|
||||
let matchesStatus = true;
|
||||
if (filterStatus === "completed") {
|
||||
matchesStatus = !!purchase.actual_date;
|
||||
} else if (filterStatus === "scheduled") {
|
||||
matchesStatus = !purchase.actual_date;
|
||||
} else if (filterStatus === "archived") {
|
||||
matchesStatus = purchase.is_archived;
|
||||
}
|
||||
|
||||
return matchesSearch && matchesChannel && matchesStatus;
|
||||
});
|
||||
|
||||
// Статистика
|
||||
const stats = {
|
||||
total: purchases.length,
|
||||
completed: purchases.filter((p) => p.actual_date).length,
|
||||
scheduled: purchases.filter((p) => !p.actual_date).length,
|
||||
totalCost: purchases.reduce((sum, p) => sum + (p.cost || 0), 0),
|
||||
totalSubscriptions: purchases.reduce(
|
||||
(sum, p) => sum + p.subscriptions_count,
|
||||
0
|
||||
),
|
||||
avgCpf:
|
||||
purchases.length > 0
|
||||
? purchases.reduce((sum, p) => sum + (p.cpf || 0), 0) / purchases.length
|
||||
: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardHeader
|
||||
breadcrumbs={[{ label: "Главная", href: "/" }, { label: "Закупы" }]}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Закупы</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Рекламные размещения во внешних каналах
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/purchases/new">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Создать закуп
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg: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>
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(stats.total)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{stats.completed} завершено, {stats.scheduled} запланировано
|
||||
</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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(stats.totalCost)}
|
||||
</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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatNumber(stats.totalSubscriptions)}
|
||||
</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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
₽{formatMetric(stats.avgCpf)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
По всем закупам
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Фильтры</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Поиск по названию..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={filterChannel} onValueChange={setFilterChannel}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Целевой канал" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Все каналы</SelectItem>
|
||||
{channels.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.id}>
|
||||
{channel.title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Статус" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Все статусы</SelectItem>
|
||||
<SelectItem value="completed">Завершено</SelectItem>
|
||||
<SelectItem value="scheduled">Запланировано</SelectItem>
|
||||
<SelectItem value="archived">Архив</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
Найдено: {formatNumber(filteredPurchases.length)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Список закупов</CardTitle>
|
||||
<CardDescription>Все рекламные размещения</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : filteredPurchases.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<ShoppingCart className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-lg font-medium text-muted-foreground">
|
||||
{searchQuery ||
|
||||
filterChannel !== "all" ||
|
||||
filterStatus !== "all"
|
||||
? "Закупы не найдены"
|
||||
: "Нет закупов"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{searchQuery ||
|
||||
filterChannel !== "all" ||
|
||||
filterStatus !== "all"
|
||||
? "Попробуйте изменить фильтры"
|
||||
: "Создайте первый закуп"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Статус</TableHead>
|
||||
<TableHead>Целевой канал</TableHead>
|
||||
<TableHead>Внешний канал</TableHead>
|
||||
<TableHead>Креатив</TableHead>
|
||||
<TableHead>Дата</TableHead>
|
||||
<TableHead className="text-right">Стоимость</TableHead>
|
||||
<TableHead className="text-right">Подписчики</TableHead>
|
||||
<TableHead className="text-right">CPF</TableHead>
|
||||
<TableHead className="text-right">Конверсия</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredPurchases.map((purchase) => (
|
||||
<TableRow key={purchase.id}>
|
||||
<TableCell>
|
||||
{purchase.is_archived ? (
|
||||
<Badge variant="secondary">
|
||||
<Archive className="h-3 w-3 mr-1" />
|
||||
Архив
|
||||
</Badge>
|
||||
) : purchase.actual_date ? (
|
||||
<Badge variant="default">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Завершено
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
Запланировано
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{purchase.target_channel.title}
|
||||
</TableCell>
|
||||
<TableCell>{purchase.external_channel.title}</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">
|
||||
{purchase.creative.name}
|
||||
</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">
|
||||
{purchase.cpf ? `₽${formatMetric(purchase.cpf)}` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{purchase.conversion_rate
|
||||
? formatPercent(purchase.conversion_rate)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/purchases/${purchase.id}`}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
122
app/globals.css
Normal file
122
app/globals.css
Normal file
@@ -0,0 +1,122 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
36
app/layout.tsx
Normal file
36
app/layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "TGEX - Управление рекламными закупками",
|
||||
description:
|
||||
"Система для владельцев каналов для управления рекламными закупками в Telegram",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="ru" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
26
app/providers.tsx
Normal file
26
app/providers.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// App Providers
|
||||
// ============================================================================
|
||||
|
||||
import React from "react";
|
||||
import { AuthProvider } from "@/components/auth/auth-provider";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
|
||||
interface ProvidersProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Providers: React.FC<ProvidersProps> = ({ children }) => {
|
||||
return (
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
22
components.json
Normal file
22
components.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
154
components/auth/auth-provider.tsx
Normal file
154
components/auth/auth-provider.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Auth Provider
|
||||
// ============================================================================
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { authApi } from "@/lib/api";
|
||||
import { USE_MOCKS } from "@/lib/utils/constants";
|
||||
import type { User } from "@/lib/types/api";
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
login: (token: string) => Promise<void>;
|
||||
loginMock: () => Promise<void>;
|
||||
logout: () => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used within AuthProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// Initialize auth state on mount
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
if (!authApi.isAuthenticated()) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get user from storage first
|
||||
const storedUser = authApi.getUserFromStorage();
|
||||
if (storedUser) {
|
||||
setUser(storedUser);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If no stored user, fetch from API
|
||||
const userData = await authApi.me();
|
||||
setUser(userData);
|
||||
} catch (err: any) {
|
||||
console.error("Auth initialization error:", err);
|
||||
// Clear invalid tokens
|
||||
authApi.logout();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
// Login with token from Telegram bot
|
||||
const login = useCallback(
|
||||
async (token: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await authApi.complete(token);
|
||||
setUser(response.user);
|
||||
|
||||
// Redirect to home
|
||||
router.push("/");
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.error?.message || "Ошибка авторизации";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// Mock login (for development without backend)
|
||||
const loginMock = useCallback(async () => {
|
||||
if (!USE_MOCKS) {
|
||||
throw new Error("Mock login is only available in mock mode");
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Use mock token
|
||||
await login("mock_token_123");
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.error?.message || "Ошибка мок-авторизации";
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [login]);
|
||||
|
||||
// Logout
|
||||
const logout = useCallback(() => {
|
||||
authApi.logout();
|
||||
setUser(null);
|
||||
router.push("/login");
|
||||
}, [router]);
|
||||
|
||||
// Refresh user data
|
||||
const refreshUser = useCallback(async () => {
|
||||
try {
|
||||
const userData = await authApi.me();
|
||||
setUser(userData);
|
||||
} catch (err) {
|
||||
console.error("Error refreshing user:", err);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
login,
|
||||
loginMock,
|
||||
logout,
|
||||
refreshUser,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
41
components/auth/protected-route.tsx
Normal file
41
components/auth/protected-route.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Protected Route Component
|
||||
// ============================================================================
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "./auth-provider";
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
router.push("/login");
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
160
components/layout/app-sidebar.tsx
Normal file
160
components/layout/app-sidebar.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Application Sidebar
|
||||
// ============================================================================
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Folder,
|
||||
LayoutDashboard,
|
||||
Megaphone,
|
||||
ShoppingCart,
|
||||
Target,
|
||||
TrendingUp,
|
||||
Settings2,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
|
||||
import { NavMain } from "@/components/nav-main";
|
||||
import { NavUser } from "@/components/nav-user";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarRail,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
|
||||
const data = {
|
||||
navMain: [
|
||||
{
|
||||
title: "Главная",
|
||||
url: "/",
|
||||
icon: LayoutDashboard,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
title: "Целевые каналы",
|
||||
url: "/channels",
|
||||
icon: Target,
|
||||
items: [
|
||||
{
|
||||
title: "Все каналы",
|
||||
url: "/channels",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Внешние каналы",
|
||||
url: "/external-channels",
|
||||
icon: ExternalLink,
|
||||
items: [
|
||||
{
|
||||
title: "Каталог",
|
||||
url: "/external-channels",
|
||||
},
|
||||
{
|
||||
title: "Импорт",
|
||||
url: "/external-channels/import",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Креативы",
|
||||
url: "/creatives",
|
||||
icon: Folder,
|
||||
items: [
|
||||
{
|
||||
title: "Все креативы",
|
||||
url: "/creatives",
|
||||
},
|
||||
{
|
||||
title: "Создать",
|
||||
url: "/creatives/new",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Закупы",
|
||||
url: "/purchases",
|
||||
icon: ShoppingCart,
|
||||
items: [
|
||||
{
|
||||
title: "Все закупы",
|
||||
url: "/purchases",
|
||||
},
|
||||
{
|
||||
title: "Создать",
|
||||
url: "/purchases/new",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Аналитика",
|
||||
url: "/analytics",
|
||||
icon: BarChart3,
|
||||
items: [
|
||||
{
|
||||
title: "Обзор",
|
||||
url: "/analytics",
|
||||
},
|
||||
{
|
||||
title: "Затраты",
|
||||
url: "/analytics/costs",
|
||||
},
|
||||
{
|
||||
title: "По каналам",
|
||||
url: "/analytics/channels",
|
||||
},
|
||||
{
|
||||
title: "По креативам",
|
||||
url: "/analytics/creatives",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center gap-2 px-2 py-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Megaphone className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">TGEX</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
Управление закупками
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={data.navMain} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
{user && (
|
||||
<NavUser
|
||||
user={{
|
||||
name: `${user.first_name}${
|
||||
user.last_name ? " " + user.last_name : ""
|
||||
}`,
|
||||
email: user.username
|
||||
? `@${user.username}`
|
||||
: `ID: ${user.telegram_id}`,
|
||||
avatar: `https://ui-avatars.com/api/?name=${user.first_name}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
61
components/layout/dashboard-header.tsx
Normal file
61
components/layout/dashboard-header.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================================
|
||||
// Dashboard Header
|
||||
// ============================================================================
|
||||
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import React from "react";
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface DashboardHeaderProps {
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
}
|
||||
|
||||
export const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
breadcrumbs = [],
|
||||
}) => {
|
||||
return (
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||
{breadcrumbs.length > 0 && (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{breadcrumbs.map((item, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{index > 0 && <BreadcrumbSeparator />}
|
||||
<BreadcrumbItem>
|
||||
{item.href ? (
|
||||
<BreadcrumbLink href={item.href}>
|
||||
{item.label}
|
||||
</BreadcrumbLink>
|
||||
) : (
|
||||
<BreadcrumbPage>{item.label}</BreadcrumbPage>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
159
components/nav-main.tsx
Normal file
159
components/nav-main.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, type LucideIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
|
||||
export function NavMain({
|
||||
items,
|
||||
}: {
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: LucideIcon;
|
||||
isActive?: boolean;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
}[];
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const { state } = useSidebar();
|
||||
const isCollapsed = state === "collapsed";
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const hasSubmenu = item.items && item.items.length > 0;
|
||||
const isActive = pathname === item.url;
|
||||
const hasActiveSubmenu =
|
||||
hasSubmenu && item.items!.some((sub) => pathname === sub.url);
|
||||
|
||||
// В свернутом состоянии показываем DropdownMenu для элементов с подменю
|
||||
if (isCollapsed && hasSubmenu) {
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={isActive || hasActiveSubmenu}
|
||||
>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="min-w-[200px]"
|
||||
>
|
||||
{item.items!.map((subItem) => {
|
||||
const isSubActive = pathname === subItem.url;
|
||||
return (
|
||||
<DropdownMenuItem key={subItem.title} asChild>
|
||||
<Link
|
||||
href={subItem.url}
|
||||
className={
|
||||
isSubActive
|
||||
? "bg-accent text-accent-foreground"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{subItem.title}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
// Обычное отображение для развернутого состояния
|
||||
return (
|
||||
<Collapsible
|
||||
key={item.title}
|
||||
asChild
|
||||
defaultOpen={isActive || hasActiveSubmenu}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
{hasSubmenu ? (
|
||||
<>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={isActive || hasActiveSubmenu}
|
||||
>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items!.map((subItem) => {
|
||||
const isSubActive = pathname === subItem.url;
|
||||
return (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={isSubActive}
|
||||
>
|
||||
<Link href={subItem.url}>
|
||||
<span>{subItem.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</>
|
||||
) : (
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
tooltip={item.title}
|
||||
isActive={isActive}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
104
components/nav-user.tsx
Normal file
104
components/nav-user.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { LogOut, User } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
|
||||
export function NavUser({
|
||||
user,
|
||||
}: {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
};
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
const { logout } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">
|
||||
{getInitials(user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-56 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">
|
||||
{getInitials(user.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{user.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Выйти
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
11
components/theme-provider.tsx
Normal file
11
components/theme-provider.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
40
components/theme-toggle.tsx
Normal file
40
components/theme-toggle.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Переключить тему</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
Светлая
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
Темная
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
Системная
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
66
components/ui/alert.tsx
Normal file
66
components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
53
components/ui/avatar.tsx
Normal file
53
components/ui/avatar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
46
components/ui/badge.tsx
Normal file
46
components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
109
components/ui/breadcrumb.tsx
Normal file
109
components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
60
components/ui/button.tsx
Normal file
60
components/ui/button.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
216
components/ui/calendar.tsx
Normal file
216
components/ui/calendar.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"flex gap-4 flex-col md:flex-row relative",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute bg-popover inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"select-none w-(--cell-size)",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] select-none text-muted-foreground",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
props.showWeekNumber
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"rounded-l-md bg-accent",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
92
components/ui/card.tsx
Normal file
92
components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
357
components/ui/chart.tsx
Normal file
357
components/ui/chart.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
}) {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-slot="chart"
|
||||
data-chart={chartId}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}) {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
32
components/ui/checkbox.tsx
Normal file
32
components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
33
components/ui/collapsible.tsx
Normal file
33
components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
143
components/ui/dialog.tsx
Normal file
143
components/ui/dialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
135
components/ui/drawer.tsx
Normal file
135
components/ui/drawer.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
257
components/ui/dropdown-menu.tsx
Normal file
257
components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
167
components/ui/form.tsx
Normal file
167
components/ui/form.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
21
components/ui/input.tsx
Normal file
21
components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
24
components/ui/label.tsx
Normal file
24
components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
48
components/ui/popover.tsx
Normal file
48
components/ui/popover.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
45
components/ui/radio-group.tsx
Normal file
45
components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
187
components/ui/select.tsx
Normal file
187
components/ui/select.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
28
components/ui/separator.tsx
Normal file
28
components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
139
components/ui/sheet.tsx
Normal file
139
components/ui/sheet.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
726
components/ui/sidebar.tsx
Normal file
726
components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("size-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!tooltip) {
|
||||
return button
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
13
components/ui/skeleton.tsx
Normal file
13
components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
40
components/ui/sonner.tsx
Normal file
40
components/ui/sonner.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
116
components/ui/table.tsx
Normal file
116
components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
66
components/ui/tabs.tsx
Normal file
66
components/ui/tabs.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
18
components/ui/textarea.tsx
Normal file
18
components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
83
components/ui/toggle-group.tsx
Normal file
83
components/ui/toggle-group.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 0,
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
|
||||
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
47
components/ui/toggle.tsx
Normal file
47
components/ui/toggle.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
61
components/ui/tooltip.tsx
Normal file
61
components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
19
hooks/use-mobile.ts
Normal file
19
hooks/use-mobile.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
43
lib/api/analytics.ts
Normal file
43
lib/api/analytics.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// ============================================================================
|
||||
// Analytics API
|
||||
// ============================================================================
|
||||
|
||||
import { api } from "./client";
|
||||
import type {
|
||||
AnalyticsOverview,
|
||||
CostsReport,
|
||||
AnalyticsChannelData,
|
||||
AnalyticsCreativeData,
|
||||
} from "@/lib/types/api";
|
||||
|
||||
export const analyticsApi = {
|
||||
/**
|
||||
* Get overview analytics
|
||||
*/
|
||||
overview: (params?: { target_channel_id?: string }) =>
|
||||
api.get<AnalyticsOverview>("/analytics/overview", { params }),
|
||||
|
||||
/**
|
||||
* Get costs report
|
||||
*/
|
||||
costs: (params: {
|
||||
period?: "day" | "week" | "month";
|
||||
target_channel_id?: string;
|
||||
}) => api.get<CostsReport>("/analytics/costs", { params }),
|
||||
|
||||
/**
|
||||
* Get channels analytics
|
||||
*/
|
||||
channels: (params?: { target_channel_id?: string }) =>
|
||||
api.get<{ data: AnalyticsChannelData[] }>("/analytics/channels", {
|
||||
params,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get creatives analytics
|
||||
*/
|
||||
creatives: (params?: { target_channel_id?: string }) =>
|
||||
api.get<{ data: AnalyticsCreativeData[] }>("/analytics/creatives", {
|
||||
params,
|
||||
}),
|
||||
};
|
||||
109
lib/api/auth.ts
Normal file
109
lib/api/auth.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
// ============================================================================
|
||||
// Auth API
|
||||
// ============================================================================
|
||||
|
||||
import { api, saveAuthTokens, clearAuthTokens } from "./client";
|
||||
import { STORAGE_KEYS } from "@/lib/utils/constants";
|
||||
import type {
|
||||
User,
|
||||
AuthInitResponse,
|
||||
AuthCompleteRequest,
|
||||
AuthCompleteResponse,
|
||||
AuthRefreshRequest,
|
||||
AuthRefreshResponse,
|
||||
} from "@/lib/types/api";
|
||||
|
||||
export const authApi = {
|
||||
/**
|
||||
* Initialize Telegram auth flow
|
||||
*/
|
||||
init: () => api.get<AuthInitResponse>("/auth/init", { requireAuth: false }),
|
||||
|
||||
/**
|
||||
* Complete auth with one-time token from bot
|
||||
*/
|
||||
complete: async (token: string): Promise<AuthCompleteResponse> => {
|
||||
const data: AuthCompleteRequest = { token };
|
||||
const response = await api.post<AuthCompleteResponse>(
|
||||
"/auth/complete",
|
||||
data,
|
||||
{
|
||||
requireAuth: false,
|
||||
}
|
||||
);
|
||||
|
||||
// Save tokens to storage
|
||||
saveAuthTokens(response.access_token, response.refresh_token);
|
||||
|
||||
// Save user to storage
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(response.user));
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
*/
|
||||
refresh: async (): Promise<string> => {
|
||||
if (typeof window === "undefined") {
|
||||
throw new Error("Cannot refresh token on server");
|
||||
}
|
||||
|
||||
const refreshToken = localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
if (!refreshToken) {
|
||||
throw new Error("No refresh token available");
|
||||
}
|
||||
|
||||
const data: AuthRefreshRequest = { refresh_token: refreshToken };
|
||||
const response = await api.post<AuthRefreshResponse>(
|
||||
"/auth/refresh",
|
||||
data,
|
||||
{
|
||||
requireAuth: false,
|
||||
}
|
||||
);
|
||||
|
||||
// Update access token
|
||||
localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, response.access_token);
|
||||
|
||||
return response.access_token;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current user info
|
||||
*/
|
||||
me: () => api.get<User>("/auth/me"),
|
||||
|
||||
/**
|
||||
* Logout (clear tokens)
|
||||
*/
|
||||
logout: () => {
|
||||
clearAuthTokens();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user from storage (no API call)
|
||||
*/
|
||||
getUserFromStorage: (): User | null => {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
const userJson = localStorage.getItem(STORAGE_KEYS.USER);
|
||||
if (!userJson) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(userJson);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
isAuthenticated: (): boolean => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return !!localStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
|
||||
},
|
||||
};
|
||||
37
lib/api/channels.ts
Normal file
37
lib/api/channels.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// ============================================================================
|
||||
// Target Channels API
|
||||
// ============================================================================
|
||||
|
||||
import { api } from "./client";
|
||||
import type {
|
||||
TargetChannel,
|
||||
TargetChannelDetail,
|
||||
TargetChannelQueryParams,
|
||||
TargetChannelUpdateRequest,
|
||||
ListResponse,
|
||||
SuccessResponse,
|
||||
} from "@/lib/types/api";
|
||||
|
||||
export const channelsApi = {
|
||||
/**
|
||||
* Get list of target channels
|
||||
*/
|
||||
list: (params?: TargetChannelQueryParams) =>
|
||||
api.get<ListResponse<TargetChannel>>("/target-channels", { params }),
|
||||
|
||||
/**
|
||||
* Get target channel details
|
||||
*/
|
||||
get: (id: string) => api.get<TargetChannelDetail>(`/target-channels/${id}`),
|
||||
|
||||
/**
|
||||
* Update target channel (is_active)
|
||||
*/
|
||||
update: (id: string, data: TargetChannelUpdateRequest) =>
|
||||
api.patch<TargetChannel>(`/target-channels/${id}`, data),
|
||||
|
||||
/**
|
||||
* Delete (disconnect) target channel
|
||||
*/
|
||||
delete: (id: string) => api.delete<SuccessResponse>(`/target-channels/${id}`),
|
||||
};
|
||||
347
lib/api/client.ts
Normal file
347
lib/api/client.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
// ============================================================================
|
||||
// API Client with Mock Support
|
||||
// ============================================================================
|
||||
|
||||
import { API_URL, USE_MOCKS, STORAGE_KEYS } from "@/lib/utils/constants";
|
||||
import { mockApiHandlers } from "@/lib/mocks/handlers";
|
||||
import type { ApiError } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
params?: Record<string, any>;
|
||||
requireAuth?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build URL with query parameters
|
||||
*/
|
||||
const buildUrl = (endpoint: string, params?: Record<string, any>): string => {
|
||||
if (!params || Object.keys(params).length === 0) {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
return queryString ? `${endpoint}?${queryString}` : endpoint;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get auth token from storage
|
||||
*/
|
||||
const getAuthToken = (): string | null => {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear auth tokens from storage
|
||||
*/
|
||||
export const clearAuthTokens = (): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN);
|
||||
localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
|
||||
localStorage.removeItem(STORAGE_KEYS.USER);
|
||||
};
|
||||
|
||||
/**
|
||||
* Save auth tokens to storage
|
||||
*/
|
||||
export const saveAuthTokens = (
|
||||
accessToken: string,
|
||||
refreshToken: string
|
||||
): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, accessToken);
|
||||
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Mock Router
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Route request to appropriate mock handler
|
||||
*/
|
||||
const routeMockRequest = async (
|
||||
endpoint: string,
|
||||
options: RequestOptions
|
||||
): Promise<any> => {
|
||||
const method = options.method || "GET";
|
||||
const params = options.params || {};
|
||||
const body = options.body ? JSON.parse(options.body as string) : undefined;
|
||||
|
||||
console.log(`[MOCK API] ${method} ${endpoint}`, { params, body });
|
||||
|
||||
// Auth endpoints
|
||||
if (endpoint === "/auth/init") {
|
||||
return mockApiHandlers.authInit();
|
||||
}
|
||||
if (endpoint === "/auth/complete") {
|
||||
return mockApiHandlers.authComplete(body);
|
||||
}
|
||||
if (endpoint === "/auth/refresh") {
|
||||
return mockApiHandlers.authRefresh(body);
|
||||
}
|
||||
if (endpoint === "/auth/me") {
|
||||
return mockApiHandlers.authMe();
|
||||
}
|
||||
|
||||
// Target Channels
|
||||
if (endpoint === "/target-channels" && method === "GET") {
|
||||
return mockApiHandlers.getTargetChannels(params);
|
||||
}
|
||||
if (endpoint.startsWith("/target-channels/") && method === "GET") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.getTargetChannel(id);
|
||||
}
|
||||
if (endpoint.startsWith("/target-channels/") && method === "PATCH") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.updateTargetChannel(id, body);
|
||||
}
|
||||
if (endpoint.startsWith("/target-channels/") && method === "DELETE") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.deleteTargetChannel(id);
|
||||
}
|
||||
|
||||
// External Channels
|
||||
if (endpoint === "/external-channels" && method === "GET") {
|
||||
return mockApiHandlers.getExternalChannels(params);
|
||||
}
|
||||
if (endpoint === "/external-channels" && method === "POST") {
|
||||
return mockApiHandlers.createExternalChannel(body);
|
||||
}
|
||||
if (
|
||||
endpoint.startsWith("/external-channels/") &&
|
||||
!endpoint.includes("/import") &&
|
||||
method === "GET"
|
||||
) {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.getExternalChannel(id);
|
||||
}
|
||||
if (endpoint.startsWith("/external-channels/") && method === "PATCH") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.updateExternalChannel(id, body);
|
||||
}
|
||||
if (endpoint.startsWith("/external-channels/") && method === "DELETE") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.deleteExternalChannel(id);
|
||||
}
|
||||
if (endpoint === "/external-channels/import" && method === "POST") {
|
||||
// FormData для импорта
|
||||
const formData = body as any;
|
||||
return mockApiHandlers.importExternalChannels(
|
||||
formData.file,
|
||||
formData.target_channel_id
|
||||
);
|
||||
}
|
||||
|
||||
// Creatives
|
||||
if (endpoint === "/creatives" && method === "GET") {
|
||||
return mockApiHandlers.getCreatives(params);
|
||||
}
|
||||
if (endpoint === "/creatives" && method === "POST") {
|
||||
return mockApiHandlers.createCreative(body);
|
||||
}
|
||||
if (endpoint.startsWith("/creatives/") && method === "GET") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.getCreative(id);
|
||||
}
|
||||
if (endpoint.startsWith("/creatives/") && method === "PATCH") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.updateCreative(id, body);
|
||||
}
|
||||
if (endpoint.startsWith("/creatives/") && method === "DELETE") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.deleteCreative(id);
|
||||
}
|
||||
|
||||
// Purchases
|
||||
if (endpoint === "/purchases" && method === "GET") {
|
||||
return mockApiHandlers.getPurchases(params);
|
||||
}
|
||||
if (endpoint === "/purchases" && method === "POST") {
|
||||
return mockApiHandlers.createPurchase(body);
|
||||
}
|
||||
if (endpoint.match(/^\/purchases\/[^\/]+$/) && method === "GET") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.getPurchase(id);
|
||||
}
|
||||
if (endpoint.match(/^\/purchases\/[^\/]+$/) && method === "PATCH") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.updatePurchase(id, body);
|
||||
}
|
||||
if (endpoint.match(/^\/purchases\/[^\/]+$/) && method === "DELETE") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.deletePurchase(id);
|
||||
}
|
||||
if (endpoint.includes("/refresh-views") && method === "POST") {
|
||||
const id = endpoint.split("/")[2];
|
||||
return mockApiHandlers.refreshPurchaseViews(id);
|
||||
}
|
||||
|
||||
// Analytics
|
||||
if (endpoint === "/analytics/overview") {
|
||||
return mockApiHandlers.getAnalyticsOverview(params);
|
||||
}
|
||||
if (endpoint === "/analytics/costs") {
|
||||
return mockApiHandlers.getAnalyticsCosts(params);
|
||||
}
|
||||
if (endpoint === "/analytics/channels") {
|
||||
return mockApiHandlers.getAnalyticsChannels(params);
|
||||
}
|
||||
if (endpoint === "/analytics/creatives") {
|
||||
return mockApiHandlers.getAnalyticsCreatives(params);
|
||||
}
|
||||
|
||||
throw {
|
||||
error: {
|
||||
code: "NOT_IMPLEMENTED",
|
||||
message: `Mock handler not implemented for ${method} ${endpoint}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// API Client
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Main API request function with mock support
|
||||
*/
|
||||
export const apiRequest = async <T = any>(
|
||||
endpoint: string,
|
||||
options: RequestOptions = {}
|
||||
): Promise<T> => {
|
||||
const { params, requireAuth = true, ...fetchOptions } = options;
|
||||
|
||||
// Use mocks if enabled
|
||||
if (USE_MOCKS) {
|
||||
try {
|
||||
const result = await routeMockRequest(endpoint, options);
|
||||
return result as T;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Real API request
|
||||
const url = buildUrl(`${API_URL}${endpoint}`, params);
|
||||
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/json",
|
||||
...fetchOptions.headers,
|
||||
};
|
||||
|
||||
// Add auth token if required
|
||||
if (requireAuth) {
|
||||
const token = getAuthToken();
|
||||
if (token) {
|
||||
(headers as Record<string, string>)["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
});
|
||||
|
||||
// Parse response
|
||||
const data = await response.json();
|
||||
|
||||
// Handle errors
|
||||
if (!response.ok) {
|
||||
const error: ApiError = data;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data as T;
|
||||
} catch (error: any) {
|
||||
// Network error or parsing error
|
||||
if (!error.error) {
|
||||
throw {
|
||||
error: {
|
||||
code: "NETWORK_ERROR",
|
||||
message: error.message || "Network error occurred",
|
||||
},
|
||||
} as ApiError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Convenience Methods
|
||||
// ============================================================================
|
||||
|
||||
export const api = {
|
||||
get: <T = any>(endpoint: string, options?: RequestOptions) =>
|
||||
apiRequest<T>(endpoint, { ...options, method: "GET" }),
|
||||
|
||||
post: <T = any>(endpoint: string, body?: any, options?: RequestOptions) =>
|
||||
apiRequest<T>(endpoint, {
|
||||
...options,
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
patch: <T = any>(endpoint: string, body?: any, options?: RequestOptions) =>
|
||||
apiRequest<T>(endpoint, {
|
||||
...options,
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
|
||||
delete: <T = any>(endpoint: string, options?: RequestOptions) =>
|
||||
apiRequest<T>(endpoint, { ...options, method: "DELETE" }),
|
||||
|
||||
// Special method for file upload
|
||||
upload: async <T = any>(
|
||||
endpoint: string,
|
||||
formData: FormData,
|
||||
options?: RequestOptions
|
||||
): Promise<T> => {
|
||||
if (USE_MOCKS) {
|
||||
// Handle mock file upload
|
||||
const file = formData.get("file") as File;
|
||||
const targetChannelId = formData.get("target_channel_id") as string;
|
||||
return mockApiHandlers.importExternalChannels(file, targetChannelId) as T;
|
||||
}
|
||||
|
||||
const url = `${API_URL}${endpoint}`;
|
||||
const token = getAuthToken();
|
||||
|
||||
const headers: HeadersInit = {};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
...options,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw data as ApiError;
|
||||
}
|
||||
|
||||
return data as T;
|
||||
},
|
||||
};
|
||||
44
lib/api/creatives.ts
Normal file
44
lib/api/creatives.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// ============================================================================
|
||||
// Creatives API
|
||||
// ============================================================================
|
||||
|
||||
import { api } from "./client";
|
||||
import type {
|
||||
Creative,
|
||||
CreativeDetail,
|
||||
CreativeQueryParams,
|
||||
CreativeCreateRequest,
|
||||
CreativeUpdateRequest,
|
||||
ListResponse,
|
||||
SuccessResponse,
|
||||
} from "@/lib/types/api";
|
||||
|
||||
export const creativesApi = {
|
||||
/**
|
||||
* Get list of creatives
|
||||
*/
|
||||
list: (params?: CreativeQueryParams) =>
|
||||
api.get<ListResponse<Creative>>("/creatives", { params }),
|
||||
|
||||
/**
|
||||
* Get creative details
|
||||
*/
|
||||
get: (id: string) => api.get<CreativeDetail>(`/creatives/${id}`),
|
||||
|
||||
/**
|
||||
* Create new creative
|
||||
*/
|
||||
create: (data: CreativeCreateRequest) =>
|
||||
api.post<Creative>("/creatives", data),
|
||||
|
||||
/**
|
||||
* Update creative
|
||||
*/
|
||||
update: (id: string, data: CreativeUpdateRequest) =>
|
||||
api.patch<Creative>(`/creatives/${id}`, data),
|
||||
|
||||
/**
|
||||
* Delete creative
|
||||
*/
|
||||
delete: (id: string) => api.delete<SuccessResponse>(`/creatives/${id}`),
|
||||
};
|
||||
61
lib/api/external-channels.ts
Normal file
61
lib/api/external-channels.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// ============================================================================
|
||||
// External Channels API
|
||||
// ============================================================================
|
||||
|
||||
import { api } from "./client";
|
||||
import type {
|
||||
ExternalChannel,
|
||||
ExternalChannelDetail,
|
||||
ExternalChannelQueryParams,
|
||||
ExternalChannelCreateRequest,
|
||||
ExternalChannelUpdateRequest,
|
||||
ExternalChannelImportResponse,
|
||||
ListResponse,
|
||||
SuccessResponse,
|
||||
} from "@/lib/types/api";
|
||||
|
||||
export const externalChannelsApi = {
|
||||
/**
|
||||
* Get list of external channels
|
||||
*/
|
||||
list: (params?: ExternalChannelQueryParams) =>
|
||||
api.get<ListResponse<ExternalChannel>>("/external-channels", { params }),
|
||||
|
||||
/**
|
||||
* Get external channel details
|
||||
*/
|
||||
get: (id: string) =>
|
||||
api.get<ExternalChannelDetail>(`/external-channels/${id}`),
|
||||
|
||||
/**
|
||||
* Create new external channel
|
||||
*/
|
||||
create: (data: ExternalChannelCreateRequest) =>
|
||||
api.post<ExternalChannel>("/external-channels", data),
|
||||
|
||||
/**
|
||||
* Update external channel
|
||||
*/
|
||||
update: (id: string, data: ExternalChannelUpdateRequest) =>
|
||||
api.patch<ExternalChannel>(`/external-channels/${id}`, data),
|
||||
|
||||
/**
|
||||
* Delete external channel
|
||||
*/
|
||||
delete: (id: string) =>
|
||||
api.delete<SuccessResponse>(`/external-channels/${id}`),
|
||||
|
||||
/**
|
||||
* Import external channels from Excel file
|
||||
*/
|
||||
import: (file: File, targetChannelId: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("target_channel_id", targetChannelId);
|
||||
|
||||
return api.upload<ExternalChannelImportResponse>(
|
||||
"/external-channels/import",
|
||||
formData
|
||||
);
|
||||
},
|
||||
};
|
||||
11
lib/api/index.ts
Normal file
11
lib/api/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// ============================================================================
|
||||
// API Export
|
||||
// ============================================================================
|
||||
|
||||
export * from "./client";
|
||||
export * from "./auth";
|
||||
export * from "./channels";
|
||||
export * from "./external-channels";
|
||||
export * from "./creatives";
|
||||
export * from "./purchases";
|
||||
export * from "./analytics";
|
||||
52
lib/api/purchases.ts
Normal file
52
lib/api/purchases.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// ============================================================================
|
||||
// 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`),
|
||||
};
|
||||
277
lib/mocks/data/analytics.ts
Normal file
277
lib/mocks/data/analytics.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import type {
|
||||
AnalyticsOverview,
|
||||
CostsReport,
|
||||
AnalyticsChannelData,
|
||||
AnalyticsCreativeData,
|
||||
} from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Mock Analytics Data
|
||||
// ============================================================================
|
||||
|
||||
export const mockAnalyticsOverview: AnalyticsOverview = {
|
||||
totalPurchases: 25,
|
||||
purchasesChange: 5,
|
||||
totalFollowers: 2500,
|
||||
followersChange: 320,
|
||||
averageCpf: 50.0,
|
||||
cpfChange: -5.2,
|
||||
averageCpm: 450.0,
|
||||
cpmChange: -3.8,
|
||||
topChannelsByCpf: [
|
||||
{
|
||||
channel_id: "ec3",
|
||||
channel_name: "IT и Технологии",
|
||||
total_purchases: 3,
|
||||
avg_cpf: 38.2,
|
||||
},
|
||||
{
|
||||
channel_id: "ec1",
|
||||
channel_name: "Канал о Маркетинге",
|
||||
total_purchases: 5,
|
||||
avg_cpf: 40.0,
|
||||
},
|
||||
{
|
||||
channel_id: "ec5",
|
||||
channel_name: "Продажи и Переговоры",
|
||||
total_purchases: 4,
|
||||
avg_cpf: 45.0,
|
||||
},
|
||||
{
|
||||
channel_id: "ec2",
|
||||
channel_name: "Бизнес и Стартапы",
|
||||
total_purchases: 8,
|
||||
avg_cpf: 55.8,
|
||||
},
|
||||
{
|
||||
channel_id: "ec4",
|
||||
channel_name: "Крипто Инвестиции",
|
||||
total_purchases: 2,
|
||||
avg_cpf: 68.5,
|
||||
},
|
||||
],
|
||||
topCreativesByCpf: [
|
||||
{
|
||||
creative_id: "cr1",
|
||||
creative_name: 'Креатив "Присоединяйся"',
|
||||
total_subscriptions: 1180,
|
||||
avg_cpf: 42.4,
|
||||
},
|
||||
{
|
||||
creative_id: "cr2",
|
||||
creative_name: 'Креатив "Эксклюзив"',
|
||||
total_subscriptions: 577,
|
||||
avg_cpf: 48.5,
|
||||
},
|
||||
{
|
||||
creative_id: "cr3",
|
||||
creative_name: 'Креатив "Обучение"',
|
||||
total_subscriptions: 803,
|
||||
avg_cpf: 52.3,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Mock data for costs report
|
||||
export const mockCostsReportByDay: CostsReport = {
|
||||
totalCost: 76500,
|
||||
averageCost: 7650,
|
||||
periods: [
|
||||
{
|
||||
date: "2024-02-20",
|
||||
total_cost: 5000,
|
||||
purchases_count: 1,
|
||||
avg_cost: 5000,
|
||||
},
|
||||
{
|
||||
date: "2024-02-22",
|
||||
total_cost: 3500,
|
||||
purchases_count: 1,
|
||||
avg_cost: 3500,
|
||||
},
|
||||
{
|
||||
date: "2024-02-25",
|
||||
total_cost: 7000,
|
||||
purchases_count: 2,
|
||||
avg_cost: 3500,
|
||||
},
|
||||
{
|
||||
date: "2024-02-28",
|
||||
total_cost: 4500,
|
||||
purchases_count: 1,
|
||||
avg_cost: 4500,
|
||||
},
|
||||
{
|
||||
date: "2024-03-02",
|
||||
total_cost: 8500,
|
||||
purchases_count: 2,
|
||||
avg_cost: 4250,
|
||||
},
|
||||
{
|
||||
date: "2024-03-05",
|
||||
total_cost: 6000,
|
||||
purchases_count: 1,
|
||||
avg_cost: 6000,
|
||||
},
|
||||
{
|
||||
date: "2024-03-08",
|
||||
total_cost: 12000,
|
||||
purchases_count: 3,
|
||||
avg_cost: 4000,
|
||||
},
|
||||
{
|
||||
date: "2024-03-12",
|
||||
total_cost: 5500,
|
||||
purchases_count: 1,
|
||||
avg_cost: 5500,
|
||||
},
|
||||
{
|
||||
date: "2024-03-15",
|
||||
total_cost: 15000,
|
||||
purchases_count: 3,
|
||||
avg_cost: 5000,
|
||||
},
|
||||
{
|
||||
date: "2024-03-18",
|
||||
total_cost: 9500,
|
||||
purchases_count: 2,
|
||||
avg_cost: 4750,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const mockCostsReportByWeek: CostsReport = {
|
||||
totalCost: 125000,
|
||||
averageCost: 15625,
|
||||
periods: [
|
||||
{
|
||||
date: "2024-02-19",
|
||||
total_cost: 15500,
|
||||
purchases_count: 4,
|
||||
avg_cost: 3875,
|
||||
},
|
||||
{
|
||||
date: "2024-02-26",
|
||||
total_cost: 13000,
|
||||
purchases_count: 3,
|
||||
avg_cost: 4333,
|
||||
},
|
||||
{
|
||||
date: "2024-03-04",
|
||||
total_cost: 26500,
|
||||
purchases_count: 6,
|
||||
avg_cost: 4417,
|
||||
},
|
||||
{
|
||||
date: "2024-03-11",
|
||||
total_cost: 33000,
|
||||
purchases_count: 7,
|
||||
avg_cost: 4714,
|
||||
},
|
||||
{
|
||||
date: "2024-03-18",
|
||||
total_cost: 24500,
|
||||
purchases_count: 5,
|
||||
avg_cost: 4900,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const mockCostsReportByMonth: CostsReport = {
|
||||
totalCost: 125000,
|
||||
averageCost: 62500,
|
||||
periods: [
|
||||
{
|
||||
date: "2024-02-01",
|
||||
total_cost: 20000,
|
||||
purchases_count: 5,
|
||||
avg_cost: 4000,
|
||||
},
|
||||
{
|
||||
date: "2024-03-01",
|
||||
total_cost: 105000,
|
||||
purchases_count: 20,
|
||||
avg_cost: 5250,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Mock data for channels analytics
|
||||
export const mockAnalyticsChannelsData: AnalyticsChannelData[] = [
|
||||
{
|
||||
channel_id: "ec1",
|
||||
channel_name: "Канал о Маркетинге",
|
||||
purchases_count: 5,
|
||||
total_cost: 25000,
|
||||
total_subscriptions: 625,
|
||||
avg_cpf: 40.0,
|
||||
avg_cpm: 380.0,
|
||||
},
|
||||
{
|
||||
channel_id: "ec2",
|
||||
channel_name: "Бизнес и Стартапы",
|
||||
purchases_count: 8,
|
||||
total_cost: 48000,
|
||||
total_subscriptions: 860,
|
||||
avg_cpf: 55.8,
|
||||
avg_cpm: 450.0,
|
||||
},
|
||||
{
|
||||
channel_id: "ec3",
|
||||
channel_name: "IT и Технологии",
|
||||
purchases_count: 3,
|
||||
total_cost: 12000,
|
||||
total_subscriptions: 314,
|
||||
avg_cpf: 38.2,
|
||||
avg_cpm: 360.0,
|
||||
},
|
||||
{
|
||||
channel_id: "ec4",
|
||||
channel_name: "Крипто Инвестиции",
|
||||
purchases_count: 2,
|
||||
total_cost: 18000,
|
||||
total_subscriptions: 263,
|
||||
avg_cpf: 68.5,
|
||||
avg_cpm: 520.0,
|
||||
},
|
||||
{
|
||||
channel_id: "ec5",
|
||||
channel_name: "Продажи и Переговоры",
|
||||
purchases_count: 4,
|
||||
total_cost: 16000,
|
||||
total_subscriptions: 356,
|
||||
avg_cpf: 45.0,
|
||||
avg_cpm: 410.0,
|
||||
},
|
||||
];
|
||||
|
||||
// Mock data for creatives analytics
|
||||
export const mockAnalyticsCreativesData: AnalyticsCreativeData[] = [
|
||||
{
|
||||
creative_id: "cr1",
|
||||
creative_name: 'Креатив "Присоединяйся"',
|
||||
purchases_count: 10,
|
||||
total_cost: 50000,
|
||||
total_subscriptions: 1180,
|
||||
avg_cpf: 42.4,
|
||||
conversion_rate: 0.95,
|
||||
},
|
||||
{
|
||||
creative_id: "cr2",
|
||||
creative_name: 'Креатив "Эксклюзив"',
|
||||
purchases_count: 5,
|
||||
total_cost: 28000,
|
||||
total_subscriptions: 577,
|
||||
avg_cpf: 48.5,
|
||||
conversion_rate: 0.88,
|
||||
},
|
||||
{
|
||||
creative_id: "cr3",
|
||||
creative_name: 'Креатив "Обучение"',
|
||||
purchases_count: 8,
|
||||
total_cost: 42000,
|
||||
total_subscriptions: 803,
|
||||
avg_cpf: 52.3,
|
||||
conversion_rate: 0.92,
|
||||
},
|
||||
];
|
||||
79
lib/mocks/data/channels.ts
Normal file
79
lib/mocks/data/channels.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { TargetChannel, TargetChannelDetail } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Mock Target Channels Data
|
||||
// ============================================================================
|
||||
|
||||
export const mockTargetChannels: TargetChannel[] = [
|
||||
{
|
||||
id: "tc1",
|
||||
telegram_id: -1001234567890,
|
||||
title: "Мой Главный Канал",
|
||||
username: "my_main_channel",
|
||||
description: "Основной канал для продвижения продукта",
|
||||
is_active: true,
|
||||
created_at: "2024-01-10T10:00:00.000Z",
|
||||
updated_at: "2024-01-10T10:00:00.000Z",
|
||||
total_purchases: 15,
|
||||
total_subscriptions: 1250,
|
||||
avg_cpf: 45.5,
|
||||
},
|
||||
{
|
||||
id: "tc2",
|
||||
telegram_id: -1009876543210,
|
||||
title: "Тестовый Канал",
|
||||
username: "test_channel_promo",
|
||||
description: "Канал для тестирования рекламных креативов",
|
||||
is_active: true,
|
||||
created_at: "2024-02-15T12:00:00.000Z",
|
||||
updated_at: "2024-02-15T12:00:00.000Z",
|
||||
total_purchases: 8,
|
||||
total_subscriptions: 450,
|
||||
avg_cpf: 52.3,
|
||||
},
|
||||
{
|
||||
id: "tc3",
|
||||
telegram_id: -1005555555555,
|
||||
title: "Новости и Обновления",
|
||||
username: null,
|
||||
description: "Приватный канал для новостей",
|
||||
is_active: false,
|
||||
created_at: "2024-03-01T08:00:00.000Z",
|
||||
updated_at: "2024-03-20T14:30:00.000Z",
|
||||
total_purchases: 3,
|
||||
total_subscriptions: 120,
|
||||
avg_cpf: 67.8,
|
||||
},
|
||||
];
|
||||
|
||||
export const getMockTargetChannelDetail = (
|
||||
id: string
|
||||
): TargetChannelDetail | null => {
|
||||
const channel = mockTargetChannels.find((c) => c.id === id);
|
||||
if (!channel) return null;
|
||||
|
||||
return {
|
||||
...channel,
|
||||
recent_purchases: [], // Will be populated from purchases mock
|
||||
stats_by_period: [
|
||||
{
|
||||
period: "day",
|
||||
subscriptions: 25,
|
||||
cost: 1200,
|
||||
cpf: 48.0,
|
||||
},
|
||||
{
|
||||
period: "week",
|
||||
subscriptions: 180,
|
||||
cost: 8500,
|
||||
cpf: 47.2,
|
||||
},
|
||||
{
|
||||
period: "month",
|
||||
subscriptions: 750,
|
||||
cost: 35000,
|
||||
cpf: 46.7,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
71
lib/mocks/data/creatives.ts
Normal file
71
lib/mocks/data/creatives.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Creative, CreativeDetail } from "@/lib/types/api";
|
||||
import { mockTargetChannels } from "./channels";
|
||||
|
||||
// ============================================================================
|
||||
// Mock Creatives Data
|
||||
// ============================================================================
|
||||
|
||||
export const mockCreatives: Creative[] = [
|
||||
{
|
||||
id: "cr1",
|
||||
name: 'Креатив "Присоединяйся"',
|
||||
text: "🔥 Присоединяйся к нашему сообществу!\n\nУзнавай первым о новых возможностях и фишках.\n\n👉 {link}",
|
||||
target_channel_id: "tc1",
|
||||
is_archived: false,
|
||||
created_at: "2024-01-15T10:00:00.000Z",
|
||||
updated_at: "2024-01-15T10:00:00.000Z",
|
||||
target_channel: mockTargetChannels[0],
|
||||
total_purchases: 10,
|
||||
total_subscriptions: 850,
|
||||
avg_cpf: 42.3,
|
||||
},
|
||||
{
|
||||
id: "cr2",
|
||||
name: 'Креатив "Эксклюзив"',
|
||||
text: "⭐ Эксклюзивный контент только для подписчиков!\n\nНе упусти возможность быть в курсе всех трендов.\n\nПереходи: {link}",
|
||||
target_channel_id: "tc1",
|
||||
is_archived: false,
|
||||
created_at: "2024-01-20T12:00:00.000Z",
|
||||
updated_at: "2024-01-20T12:00:00.000Z",
|
||||
target_channel: mockTargetChannels[0],
|
||||
total_purchases: 5,
|
||||
total_subscriptions: 400,
|
||||
avg_cpf: 48.5,
|
||||
},
|
||||
{
|
||||
id: "cr3",
|
||||
name: 'Креатив "Обучение"',
|
||||
text: "📚 Хочешь научиться зарабатывать больше?\n\nПодписывайся на канал с проверенными методиками!\n\n{link}",
|
||||
target_channel_id: "tc2",
|
||||
is_archived: false,
|
||||
created_at: "2024-02-05T09:00:00.000Z",
|
||||
updated_at: "2024-02-05T09:00:00.000Z",
|
||||
target_channel: mockTargetChannels[1],
|
||||
total_purchases: 8,
|
||||
total_subscriptions: 450,
|
||||
avg_cpf: 52.3,
|
||||
},
|
||||
{
|
||||
id: "cr4",
|
||||
name: "Старый креатив (архив)",
|
||||
text: "Устаревший текст с предложением. {link}",
|
||||
target_channel_id: "tc1",
|
||||
is_archived: true,
|
||||
created_at: "2023-12-01T10:00:00.000Z",
|
||||
updated_at: "2024-01-10T15:00:00.000Z",
|
||||
target_channel: mockTargetChannels[0],
|
||||
total_purchases: 2,
|
||||
total_subscriptions: 80,
|
||||
avg_cpf: 75.0,
|
||||
},
|
||||
];
|
||||
|
||||
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
|
||||
};
|
||||
};
|
||||
95
lib/mocks/data/external-channels.ts
Normal file
95
lib/mocks/data/external-channels.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { ExternalChannel, ExternalChannelDetail } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Mock External Channels Data
|
||||
// ============================================================================
|
||||
|
||||
export const mockExternalChannels: ExternalChannel[] = [
|
||||
{
|
||||
id: "ec1",
|
||||
telegram_id: -1001111111111,
|
||||
title: "Канал о Маркетинге",
|
||||
username: "marketing_pro",
|
||||
link: "https://t.me/marketing_pro",
|
||||
subscribers_count: 50000,
|
||||
description: "Лучшие практики маркетинга",
|
||||
created_at: "2024-01-05T10:00:00.000Z",
|
||||
updated_at: "2024-01-05T10:00:00.000Z",
|
||||
target_channels: ["tc1", "tc2"],
|
||||
total_purchases: 5,
|
||||
avg_cpf: 42.5,
|
||||
avg_cpm: 850.0,
|
||||
},
|
||||
{
|
||||
id: "ec2",
|
||||
telegram_id: -1002222222222,
|
||||
title: "Бизнес и Стартапы",
|
||||
username: "business_startups",
|
||||
link: "https://t.me/business_startups",
|
||||
subscribers_count: 120000,
|
||||
description: "Новости мира бизнеса",
|
||||
created_at: "2024-01-08T11:00:00.000Z",
|
||||
updated_at: "2024-01-08T11:00:00.000Z",
|
||||
target_channels: ["tc1"],
|
||||
total_purchases: 8,
|
||||
avg_cpf: 55.8,
|
||||
avg_cpm: 1200.0,
|
||||
},
|
||||
{
|
||||
id: "ec3",
|
||||
telegram_id: -1003333333333,
|
||||
title: "IT и Технологии",
|
||||
username: "it_tech_news",
|
||||
link: "https://t.me/it_tech_news",
|
||||
subscribers_count: 85000,
|
||||
description: "Все о новых технологиях",
|
||||
created_at: "2024-01-12T09:00:00.000Z",
|
||||
updated_at: "2024-01-12T09:00:00.000Z",
|
||||
target_channels: ["tc1", "tc2"],
|
||||
total_purchases: 3,
|
||||
avg_cpf: 38.2,
|
||||
avg_cpm: 750.0,
|
||||
},
|
||||
{
|
||||
id: "ec4",
|
||||
telegram_id: null,
|
||||
title: "Крипто Инвестиции",
|
||||
username: "crypto_invest",
|
||||
link: "https://t.me/crypto_invest",
|
||||
subscribers_count: 200000,
|
||||
description: null,
|
||||
created_at: "2024-02-01T10:00:00.000Z",
|
||||
updated_at: "2024-02-01T10:00:00.000Z",
|
||||
target_channels: ["tc2"],
|
||||
total_purchases: 2,
|
||||
avg_cpf: 68.5,
|
||||
avg_cpm: 1500.0,
|
||||
},
|
||||
{
|
||||
id: "ec5",
|
||||
telegram_id: -1005555555555,
|
||||
title: "Продажи и Переговоры",
|
||||
username: "sales_expert",
|
||||
link: "https://t.me/sales_expert",
|
||||
subscribers_count: 35000,
|
||||
description: "Экспертиза в продажах",
|
||||
created_at: "2024-02-10T14:00:00.000Z",
|
||||
updated_at: "2024-02-10T14:00:00.000Z",
|
||||
target_channels: ["tc1"],
|
||||
total_purchases: 4,
|
||||
avg_cpf: 45.0,
|
||||
avg_cpm: 900.0,
|
||||
},
|
||||
];
|
||||
|
||||
export const getMockExternalChannelDetail = (
|
||||
id: string
|
||||
): ExternalChannelDetail | null => {
|
||||
const channel = mockExternalChannels.find((c) => c.id === id);
|
||||
if (!channel) return null;
|
||||
|
||||
return {
|
||||
...channel,
|
||||
purchases: [], // Will be populated from purchases mock
|
||||
};
|
||||
};
|
||||
264
lib/mocks/data/purchases.ts
Normal file
264
lib/mocks/data/purchases.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
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),
|
||||
};
|
||||
};
|
||||
32
lib/mocks/data/users.ts
Normal file
32
lib/mocks/data/users.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { User } from "@/lib/types/api";
|
||||
|
||||
// ============================================================================
|
||||
// Mock Users Data
|
||||
// ============================================================================
|
||||
|
||||
export const mockUsers: User[] = [
|
||||
{
|
||||
id: "1",
|
||||
telegram_id: 123456789,
|
||||
username: "ivan_test",
|
||||
first_name: "Иван",
|
||||
last_name: "Иванов",
|
||||
created_at: "2024-01-15T10:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
telegram_id: 987654321,
|
||||
username: "maria_test",
|
||||
first_name: "Мария",
|
||||
last_name: "Петрова",
|
||||
created_at: "2024-02-01T10:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
export const mockCurrentUser = mockUsers[0];
|
||||
|
||||
// Mock tokens
|
||||
export const mockTokens = {
|
||||
access_token: "mock_access_token_123456789",
|
||||
refresh_token: "mock_refresh_token_123456789",
|
||||
};
|
||||
636
lib/mocks/handlers.ts
Normal file
636
lib/mocks/handlers.ts
Normal file
@@ -0,0 +1,636 @@
|
||||
// ============================================================================
|
||||
// Mock API Handlers
|
||||
// ============================================================================
|
||||
|
||||
import {
|
||||
mockCurrentUser,
|
||||
mockTokens,
|
||||
mockTargetChannels,
|
||||
getMockTargetChannelDetail,
|
||||
mockExternalChannels,
|
||||
getMockExternalChannelDetail,
|
||||
mockCreatives,
|
||||
getMockCreativeDetail,
|
||||
mockPurchases,
|
||||
getMockPurchaseDetail,
|
||||
mockAnalyticsOverview,
|
||||
mockCostsReportByDay,
|
||||
mockCostsReportByWeek,
|
||||
mockCostsReportByMonth,
|
||||
mockAnalyticsChannelsData,
|
||||
mockAnalyticsCreativesData,
|
||||
} from "./index";
|
||||
|
||||
import type {
|
||||
ListResponse,
|
||||
SuccessResponse,
|
||||
ApiError,
|
||||
TargetChannel,
|
||||
ExternalChannel,
|
||||
Creative,
|
||||
Purchase,
|
||||
PurchaseCreateRequest,
|
||||
PurchaseCreateResponse,
|
||||
CreativeCreateRequest,
|
||||
ExternalChannelCreateRequest,
|
||||
PurchaseRefreshViewsResponse,
|
||||
ExternalChannelImportResponse,
|
||||
AuthCompleteRequest,
|
||||
AuthCompleteResponse,
|
||||
AuthRefreshRequest,
|
||||
AuthRefreshResponse,
|
||||
AuthInitResponse,
|
||||
TargetChannelUpdateRequest,
|
||||
ExternalChannelUpdateRequest,
|
||||
CreativeUpdateRequest,
|
||||
PurchaseUpdateRequest,
|
||||
} from "@/lib/types/api";
|
||||
|
||||
// Simulate network delay
|
||||
const delay = (ms: number = 300) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// Helper to generate mock error
|
||||
const mockError = (code: string, message: string): ApiError => ({
|
||||
error: { code, message },
|
||||
});
|
||||
|
||||
// Helper to generate IDs
|
||||
let idCounter = 1000;
|
||||
const generateId = () => `mock_${idCounter++}`;
|
||||
|
||||
// ============================================================================
|
||||
// Mock API Handlers
|
||||
// ============================================================================
|
||||
|
||||
export const mockApiHandlers = {
|
||||
// --------------------------------------------------------------------------
|
||||
// Auth
|
||||
// --------------------------------------------------------------------------
|
||||
async authInit(): Promise<AuthInitResponse> {
|
||||
await delay();
|
||||
return {
|
||||
bot_username: "tgex_bot",
|
||||
start_param: "login",
|
||||
};
|
||||
},
|
||||
|
||||
async authComplete(data: AuthCompleteRequest): Promise<AuthCompleteResponse> {
|
||||
await delay();
|
||||
if (!data.token) {
|
||||
throw mockError("VALIDATION_ERROR", "Token is required");
|
||||
}
|
||||
return {
|
||||
access_token: mockTokens.access_token,
|
||||
refresh_token: mockTokens.refresh_token,
|
||||
user: mockCurrentUser,
|
||||
};
|
||||
},
|
||||
|
||||
async authRefresh(data: AuthRefreshRequest): Promise<AuthRefreshResponse> {
|
||||
await delay();
|
||||
if (!data.refresh_token) {
|
||||
throw mockError("VALIDATION_ERROR", "Refresh token is required");
|
||||
}
|
||||
return {
|
||||
access_token: `${mockTokens.access_token}_refreshed`,
|
||||
};
|
||||
},
|
||||
|
||||
async authMe() {
|
||||
await delay();
|
||||
return mockCurrentUser;
|
||||
},
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Target Channels
|
||||
// --------------------------------------------------------------------------
|
||||
async getTargetChannels(params?: {
|
||||
is_active?: boolean;
|
||||
}): Promise<ListResponse<TargetChannel>> {
|
||||
await delay();
|
||||
let filtered = [...mockTargetChannels];
|
||||
|
||||
if (params?.is_active !== undefined) {
|
||||
filtered = filtered.filter((c) => c.is_active === params.is_active);
|
||||
}
|
||||
|
||||
return {
|
||||
data: filtered,
|
||||
total: filtered.length,
|
||||
};
|
||||
},
|
||||
|
||||
async getTargetChannel(id: string) {
|
||||
await delay();
|
||||
const detail = getMockTargetChannelDetail(id);
|
||||
if (!detail) {
|
||||
throw mockError("NOT_FOUND", "Target channel not found");
|
||||
}
|
||||
return detail;
|
||||
},
|
||||
|
||||
async updateTargetChannel(
|
||||
id: string,
|
||||
data: TargetChannelUpdateRequest
|
||||
): Promise<TargetChannel> {
|
||||
await delay();
|
||||
const channel = mockTargetChannels.find((c) => c.id === id);
|
||||
if (!channel) {
|
||||
throw mockError("NOT_FOUND", "Target channel not found");
|
||||
}
|
||||
return {
|
||||
...channel,
|
||||
is_active: data.is_active,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
|
||||
async deleteTargetChannel(id: string): Promise<SuccessResponse> {
|
||||
await delay();
|
||||
const channel = mockTargetChannels.find((c) => c.id === id);
|
||||
if (!channel) {
|
||||
throw mockError("NOT_FOUND", "Target channel not found");
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// External Channels
|
||||
// --------------------------------------------------------------------------
|
||||
async getExternalChannels(params?: {
|
||||
target_channel_id?: string;
|
||||
search?: string;
|
||||
}): Promise<ListResponse<ExternalChannel>> {
|
||||
await delay();
|
||||
let filtered = [...mockExternalChannels];
|
||||
|
||||
if (params?.target_channel_id) {
|
||||
filtered = filtered.filter((c) =>
|
||||
c.target_channels.includes(params.target_channel_id!)
|
||||
);
|
||||
}
|
||||
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(c) =>
|
||||
c.title.toLowerCase().includes(search) ||
|
||||
c.username?.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
data: filtered,
|
||||
total: filtered.length,
|
||||
};
|
||||
},
|
||||
|
||||
async getExternalChannel(id: string) {
|
||||
await delay();
|
||||
const detail = getMockExternalChannelDetail(id);
|
||||
if (!detail) {
|
||||
throw mockError("NOT_FOUND", "External channel not found");
|
||||
}
|
||||
return detail;
|
||||
},
|
||||
|
||||
async createExternalChannel(
|
||||
data: ExternalChannelCreateRequest
|
||||
): Promise<ExternalChannel> {
|
||||
await delay(500);
|
||||
const newChannel: ExternalChannel = {
|
||||
id: generateId(),
|
||||
telegram_id: null,
|
||||
title: data.title,
|
||||
username: data.username || null,
|
||||
link: data.link,
|
||||
subscribers_count: data.subscribers_count || null,
|
||||
description: data.description || null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
target_channels: data.target_channel_ids,
|
||||
total_purchases: 0,
|
||||
avg_cpf: null,
|
||||
avg_cpm: null,
|
||||
};
|
||||
mockExternalChannels.push(newChannel);
|
||||
return newChannel;
|
||||
},
|
||||
|
||||
async updateExternalChannel(
|
||||
id: string,
|
||||
data: ExternalChannelUpdateRequest
|
||||
): Promise<ExternalChannel> {
|
||||
await delay();
|
||||
const channel = mockExternalChannels.find((c) => c.id === id);
|
||||
if (!channel) {
|
||||
throw mockError("NOT_FOUND", "External channel not found");
|
||||
}
|
||||
return {
|
||||
...channel,
|
||||
...data,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
|
||||
async deleteExternalChannel(id: string): Promise<SuccessResponse> {
|
||||
await delay();
|
||||
const index = mockExternalChannels.findIndex((c) => c.id === id);
|
||||
if (index === -1) {
|
||||
throw mockError("NOT_FOUND", "External channel not found");
|
||||
}
|
||||
mockExternalChannels.splice(index, 1);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
async importExternalChannels(
|
||||
file: File,
|
||||
targetChannelId: string
|
||||
): Promise<ExternalChannelImportResponse> {
|
||||
await delay(1500); // Longer delay for file processing
|
||||
|
||||
// Mock: simulate importing 3-5 channels
|
||||
const importCount = Math.floor(Math.random() * 3) + 3;
|
||||
const imported: ExternalChannel[] = [];
|
||||
|
||||
for (let i = 0; i < importCount; i++) {
|
||||
const newChannel: ExternalChannel = {
|
||||
id: generateId(),
|
||||
telegram_id: null,
|
||||
title: `Импортированный канал ${i + 1}`,
|
||||
username: `imported_channel_${i + 1}`,
|
||||
link: `https://t.me/imported_channel_${i + 1}`,
|
||||
subscribers_count: Math.floor(Math.random() * 100000) + 10000,
|
||||
description: "Импортировано из Excel",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
target_channels: [targetChannelId],
|
||||
total_purchases: 0,
|
||||
avg_cpf: null,
|
||||
avg_cpm: null,
|
||||
};
|
||||
imported.push(newChannel);
|
||||
mockExternalChannels.push(newChannel);
|
||||
}
|
||||
|
||||
return {
|
||||
imported: importCount,
|
||||
skipped: 1,
|
||||
errors: ["Строка 15: некорректный формат ссылки"],
|
||||
channels: imported,
|
||||
};
|
||||
},
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Creatives
|
||||
// --------------------------------------------------------------------------
|
||||
async getCreatives(params?: {
|
||||
target_channel_id?: string;
|
||||
is_archived?: boolean;
|
||||
}): Promise<ListResponse<Creative>> {
|
||||
await delay();
|
||||
let filtered = [...mockCreatives];
|
||||
|
||||
if (params?.target_channel_id) {
|
||||
filtered = filtered.filter(
|
||||
(c) => c.target_channel_id === params.target_channel_id
|
||||
);
|
||||
}
|
||||
|
||||
if (params?.is_archived !== undefined) {
|
||||
filtered = filtered.filter((c) => c.is_archived === params.is_archived);
|
||||
}
|
||||
|
||||
return {
|
||||
data: filtered,
|
||||
total: filtered.length,
|
||||
};
|
||||
},
|
||||
|
||||
async getCreative(id: string) {
|
||||
await delay();
|
||||
const detail = getMockCreativeDetail(id);
|
||||
if (!detail) {
|
||||
throw mockError("NOT_FOUND", "Creative not found");
|
||||
}
|
||||
return detail;
|
||||
},
|
||||
|
||||
async createCreative(data: CreativeCreateRequest): Promise<Creative> {
|
||||
await delay(500);
|
||||
|
||||
// Validate {link} placeholder
|
||||
if (!data.text.includes("{link}")) {
|
||||
throw mockError(
|
||||
"VALIDATION_ERROR",
|
||||
"Creative text must contain {link} placeholder"
|
||||
);
|
||||
}
|
||||
|
||||
const targetChannel = mockTargetChannels.find(
|
||||
(c) => c.id === data.target_channel_id
|
||||
);
|
||||
if (!targetChannel) {
|
||||
throw mockError("NOT_FOUND", "Target channel not found");
|
||||
}
|
||||
|
||||
const newCreative: Creative = {
|
||||
id: generateId(),
|
||||
name: data.name,
|
||||
text: data.text,
|
||||
target_channel_id: data.target_channel_id,
|
||||
is_archived: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
target_channel: targetChannel,
|
||||
total_purchases: 0,
|
||||
total_subscriptions: 0,
|
||||
avg_cpf: null,
|
||||
};
|
||||
mockCreatives.push(newCreative);
|
||||
return newCreative;
|
||||
},
|
||||
|
||||
async updateCreative(
|
||||
id: string,
|
||||
data: CreativeUpdateRequest
|
||||
): Promise<Creative> {
|
||||
await delay();
|
||||
const creative = mockCreatives.find((c) => c.id === id);
|
||||
if (!creative) {
|
||||
throw mockError("NOT_FOUND", "Creative not found");
|
||||
}
|
||||
|
||||
if (data.text && !data.text.includes("{link}")) {
|
||||
throw mockError(
|
||||
"VALIDATION_ERROR",
|
||||
"Creative text must contain {link} placeholder"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...creative,
|
||||
...data,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
|
||||
async deleteCreative(id: string): Promise<SuccessResponse> {
|
||||
await delay();
|
||||
const creative = mockCreatives.find((c) => c.id === id);
|
||||
if (!creative) {
|
||||
throw mockError("NOT_FOUND", "Creative not found");
|
||||
}
|
||||
|
||||
if (creative.total_purchases > 0) {
|
||||
throw mockError(
|
||||
"CREATIVE_IN_USE",
|
||||
"Cannot delete creative with active purchases"
|
||||
);
|
||||
}
|
||||
|
||||
const index = mockCreatives.findIndex((c) => c.id === id);
|
||||
mockCreatives.splice(index, 1);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Purchases
|
||||
// --------------------------------------------------------------------------
|
||||
async getPurchases(params?: {
|
||||
target_channel_id?: string;
|
||||
external_channel_id?: string;
|
||||
creative_id?: string;
|
||||
is_archived?: boolean;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
sort?: string;
|
||||
order?: "asc" | "desc";
|
||||
}): Promise<ListResponse<Purchase>> {
|
||||
await delay();
|
||||
let filtered = [...mockPurchases];
|
||||
|
||||
if (params?.target_channel_id) {
|
||||
filtered = filtered.filter(
|
||||
(p) => p.target_channel_id === params.target_channel_id
|
||||
);
|
||||
}
|
||||
|
||||
if (params?.external_channel_id) {
|
||||
filtered = filtered.filter(
|
||||
(p) => p.external_channel_id === params.external_channel_id
|
||||
);
|
||||
}
|
||||
|
||||
if (params?.creative_id) {
|
||||
filtered = filtered.filter((p) => p.creative_id === params.creative_id);
|
||||
}
|
||||
|
||||
if (params?.is_archived !== undefined) {
|
||||
filtered = filtered.filter((p) => p.is_archived === params.is_archived);
|
||||
}
|
||||
|
||||
// Sorting
|
||||
if (params?.sort) {
|
||||
filtered.sort((a, b) => {
|
||||
let aVal: any;
|
||||
let bVal: any;
|
||||
|
||||
switch (params.sort) {
|
||||
case "date":
|
||||
aVal = a.actual_date || a.scheduled_date || a.created_at;
|
||||
bVal = b.actual_date || b.scheduled_date || b.created_at;
|
||||
break;
|
||||
case "cost":
|
||||
aVal = a.cost || 0;
|
||||
bVal = b.cost || 0;
|
||||
break;
|
||||
case "cpf":
|
||||
aVal = a.cpf || 0;
|
||||
bVal = b.cpf || 0;
|
||||
break;
|
||||
case "subscriptions":
|
||||
aVal = a.subscriptions_count;
|
||||
bVal = b.subscriptions_count;
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (params.order === "desc") {
|
||||
return bVal > aVal ? 1 : -1;
|
||||
}
|
||||
return aVal > bVal ? 1 : -1;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: filtered,
|
||||
total: filtered.length,
|
||||
};
|
||||
},
|
||||
|
||||
async getPurchase(id: string) {
|
||||
await delay();
|
||||
const detail = getMockPurchaseDetail(id);
|
||||
if (!detail) {
|
||||
throw mockError("NOT_FOUND", "Purchase not found");
|
||||
}
|
||||
return detail;
|
||||
},
|
||||
|
||||
async createPurchase(
|
||||
data: PurchaseCreateRequest
|
||||
): Promise<PurchaseCreateResponse> {
|
||||
await delay(800);
|
||||
|
||||
const targetChannel = mockTargetChannels.find(
|
||||
(c) => c.id === data.target_channel_id
|
||||
);
|
||||
const externalChannel = mockExternalChannels.find(
|
||||
(c) => c.id === data.external_channel_id
|
||||
);
|
||||
const creative = mockCreatives.find((c) => c.id === data.creative_id);
|
||||
|
||||
if (!targetChannel || !externalChannel || !creative) {
|
||||
throw mockError("NOT_FOUND", "One of the required entities not found");
|
||||
}
|
||||
|
||||
// Generate mock invite link
|
||||
const inviteLink = `https://t.me/+${Math.random()
|
||||
.toString(36)
|
||||
.substring(2, 15)}`;
|
||||
|
||||
const newPurchase: Purchase = {
|
||||
id: generateId(),
|
||||
target_channel_id: data.target_channel_id,
|
||||
external_channel_id: data.external_channel_id,
|
||||
creative_id: data.creative_id,
|
||||
scheduled_date: data.scheduled_date || null,
|
||||
actual_date: data.actual_date || null,
|
||||
cost: data.cost || null,
|
||||
comment: data.comment || null,
|
||||
post_link: data.post_link || null,
|
||||
invite_link: inviteLink,
|
||||
invite_link_type: data.invite_link_type,
|
||||
is_archived: false,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
target_channel: targetChannel,
|
||||
external_channel: externalChannel,
|
||||
creative: creative,
|
||||
subscriptions_count: 0,
|
||||
views_count: null,
|
||||
cpf: null,
|
||||
cpm: null,
|
||||
conversion_rate: null,
|
||||
};
|
||||
|
||||
mockPurchases.push(newPurchase);
|
||||
|
||||
// Format message with invite link
|
||||
const formattedMessage = creative.text.replace("{link}", inviteLink);
|
||||
|
||||
return {
|
||||
...newPurchase,
|
||||
formatted_message: formattedMessage,
|
||||
};
|
||||
},
|
||||
|
||||
async updatePurchase(
|
||||
id: string,
|
||||
data: PurchaseUpdateRequest
|
||||
): Promise<Purchase> {
|
||||
await delay();
|
||||
const purchase = mockPurchases.find((p) => p.id === id);
|
||||
if (!purchase) {
|
||||
throw mockError("NOT_FOUND", "Purchase not found");
|
||||
}
|
||||
|
||||
return {
|
||||
...purchase,
|
||||
...data,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
|
||||
async deletePurchase(id: string): Promise<SuccessResponse> {
|
||||
await delay();
|
||||
const index = mockPurchases.findIndex((p) => p.id === id);
|
||||
if (index === -1) {
|
||||
throw mockError("NOT_FOUND", "Purchase not found");
|
||||
}
|
||||
mockPurchases.splice(index, 1);
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
async refreshPurchaseViews(
|
||||
id: string
|
||||
): Promise<PurchaseRefreshViewsResponse> {
|
||||
await delay(1000);
|
||||
const purchase = mockPurchases.find((p) => p.id === id);
|
||||
if (!purchase) {
|
||||
throw mockError("NOT_FOUND", "Purchase not found");
|
||||
}
|
||||
|
||||
// Mock: add some random views
|
||||
const newViews =
|
||||
(purchase.views_count || 0) + Math.floor(Math.random() * 500);
|
||||
|
||||
return {
|
||||
views: newViews,
|
||||
fetched_at: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Analytics
|
||||
// --------------------------------------------------------------------------
|
||||
async getAnalyticsOverview(params?: {
|
||||
target_channel_id?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
}) {
|
||||
await delay();
|
||||
return mockAnalyticsOverview;
|
||||
},
|
||||
|
||||
async getAnalyticsCosts(params?: {
|
||||
period?: "day" | "week" | "month";
|
||||
target_channel_id?: string;
|
||||
}) {
|
||||
await delay();
|
||||
const period = params?.period || "week";
|
||||
|
||||
if (period === "day") {
|
||||
return mockCostsReportByDay;
|
||||
} else if (period === "month") {
|
||||
return mockCostsReportByMonth;
|
||||
}
|
||||
return mockCostsReportByWeek;
|
||||
},
|
||||
|
||||
async getAnalyticsChannels(params?: {
|
||||
target_channel_id?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
type?: "external" | "target";
|
||||
}) {
|
||||
await delay();
|
||||
return {
|
||||
data: mockAnalyticsChannelsData,
|
||||
};
|
||||
},
|
||||
|
||||
async getAnalyticsCreatives(params?: {
|
||||
target_channel_id?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
}) {
|
||||
await delay();
|
||||
return {
|
||||
data: mockAnalyticsCreativesData,
|
||||
};
|
||||
},
|
||||
};
|
||||
10
lib/mocks/index.ts
Normal file
10
lib/mocks/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// ============================================================================
|
||||
// Mock Data Export
|
||||
// ============================================================================
|
||||
|
||||
export * from "./data/users";
|
||||
export * from "./data/channels";
|
||||
export * from "./data/external-channels";
|
||||
export * from "./data/creatives";
|
||||
export * from "./data/purchases";
|
||||
export * from "./data/analytics";
|
||||
410
lib/types/api.ts
Normal file
410
lib/types/api.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
// ============================================================================
|
||||
// API Types - Generated from API_DOCUMENTATION.md
|
||||
// ============================================================================
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// User & Auth
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
telegram_id: number;
|
||||
username: string | null;
|
||||
first_name: string;
|
||||
last_name: string | null;
|
||||
created_at: string; // ISO 8601
|
||||
}
|
||||
|
||||
export interface AuthInitResponse {
|
||||
bot_username: string;
|
||||
start_param: string; // "login"
|
||||
}
|
||||
|
||||
export interface AuthCompleteRequest {
|
||||
token: string; // one-time token from bot
|
||||
}
|
||||
|
||||
export interface AuthCompleteResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface AuthRefreshRequest {
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
export interface AuthRefreshResponse {
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Target Channel
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface TargetChannel {
|
||||
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
|
||||
}
|
||||
|
||||
export interface TargetChannelDetail extends TargetChannel {
|
||||
recent_purchases: Purchase[]; // last 5
|
||||
stats_by_period: {
|
||||
period: "day" | "week" | "month";
|
||||
subscriptions: number;
|
||||
cost: number;
|
||||
cpf: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface TargetChannelUpdateRequest {
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// External Channel
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface ExternalChannel {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface ExternalChannelDetail extends ExternalChannel {
|
||||
purchases: Purchase[];
|
||||
}
|
||||
|
||||
export interface ExternalChannelCreateRequest {
|
||||
title: string;
|
||||
username?: string;
|
||||
link: string;
|
||||
subscribers_count?: number;
|
||||
description?: string;
|
||||
target_channel_ids: string[]; // Привязка к целевым каналам
|
||||
}
|
||||
|
||||
export interface ExternalChannelUpdateRequest {
|
||||
title?: string;
|
||||
username?: string;
|
||||
link?: string;
|
||||
subscribers_count?: number;
|
||||
description?: string;
|
||||
target_channel_ids?: string[];
|
||||
}
|
||||
|
||||
export interface ExternalChannelImportResponse {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
channels: ExternalChannel[];
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Creative
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface Creative {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface CreativeDetail extends Creative {
|
||||
purchases: Purchase[];
|
||||
}
|
||||
|
||||
export interface CreativeCreateRequest {
|
||||
name: string;
|
||||
text: string; // Must contain {link} placeholder
|
||||
target_channel_id: string;
|
||||
}
|
||||
|
||||
export interface CreativeUpdateRequest {
|
||||
name?: string;
|
||||
text?: string;
|
||||
is_archived?: boolean;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Purchase
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export type InviteLinkType = "public" | "private";
|
||||
|
||||
export interface Purchase {
|
||||
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: InviteLinkType; // with bot approval or not
|
||||
is_archived: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// Relations
|
||||
target_channel: TargetChannel;
|
||||
external_channel: ExternalChannel;
|
||||
creative: Creative;
|
||||
// Stats
|
||||
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
|
||||
}
|
||||
|
||||
export interface PurchaseDetail extends Purchase {
|
||||
subscriptions: Subscription[];
|
||||
views_history: ViewsSnapshot[];
|
||||
}
|
||||
|
||||
export interface PurchaseCreateRequest {
|
||||
target_channel_id: string;
|
||||
external_channel_id: string;
|
||||
creative_id: string;
|
||||
scheduled_date?: string;
|
||||
actual_date?: string;
|
||||
cost?: number;
|
||||
comment?: string;
|
||||
post_link?: string;
|
||||
invite_link_type: InviteLinkType;
|
||||
}
|
||||
|
||||
export interface PurchaseCreateResponse extends Purchase {
|
||||
formatted_message: string; // Creative text with invite_link inserted
|
||||
}
|
||||
|
||||
export interface PurchaseUpdateRequest {
|
||||
scheduled_date?: string;
|
||||
actual_date?: string;
|
||||
cost?: number;
|
||||
comment?: string;
|
||||
post_link?: string;
|
||||
is_archived?: boolean;
|
||||
}
|
||||
|
||||
export interface PurchaseRefreshViewsResponse {
|
||||
views: number;
|
||||
fetched_at: string;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Subscription
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
purchase_id: string;
|
||||
telegram_user_id: number;
|
||||
user_first_name: string;
|
||||
user_last_name: string | null;
|
||||
user_username: string | null;
|
||||
subscribed_at: string; // ISO 8601
|
||||
is_active: boolean;
|
||||
event_type: "chat_member" | "join_request";
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Views Snapshot
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface ViewsSnapshot {
|
||||
id: string;
|
||||
purchase_id: string;
|
||||
views: number;
|
||||
fetched_at: string; // ISO 8601
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Analytics
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface AnalyticsOverview {
|
||||
totalPurchases: number;
|
||||
purchasesChange: number;
|
||||
totalFollowers: number;
|
||||
followersChange: number;
|
||||
averageCpf: number;
|
||||
cpfChange: number;
|
||||
averageCpm: number;
|
||||
cpmChange: number;
|
||||
topChannelsByCpf: {
|
||||
channel_id: string;
|
||||
channel_name: string;
|
||||
total_purchases: number;
|
||||
avg_cpf: number;
|
||||
}[];
|
||||
topCreativesByCpf: {
|
||||
creative_id: string;
|
||||
creative_name: string;
|
||||
total_subscriptions: number;
|
||||
avg_cpf: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export type GroupByPeriod = "day" | "week" | "month" | "quarter" | "year";
|
||||
|
||||
export interface AnalyticsCostsDataPoint {
|
||||
period: string; // ISO 8601 date (start of period)
|
||||
cost: number;
|
||||
purchases_count: number;
|
||||
subscriptions: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsCostsResponse {
|
||||
data: AnalyticsCostsDataPoint[];
|
||||
}
|
||||
|
||||
export interface CostsReport {
|
||||
totalCost: number;
|
||||
averageCost: number;
|
||||
periods: {
|
||||
date: string;
|
||||
total_cost: number;
|
||||
purchases_count: number;
|
||||
avg_cost: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface AnalyticsChannelData {
|
||||
channel_id: string;
|
||||
channel_name: string;
|
||||
purchases_count: number;
|
||||
total_cost: number;
|
||||
total_subscriptions: number;
|
||||
avg_cpf: number;
|
||||
avg_cpm: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsChannelsResponse {
|
||||
data: AnalyticsChannelData[];
|
||||
}
|
||||
|
||||
export interface AnalyticsCreativeData {
|
||||
creative_id: string;
|
||||
creative_name: string;
|
||||
purchases_count: number;
|
||||
total_cost: number;
|
||||
total_subscriptions: number;
|
||||
avg_cpf: number;
|
||||
conversion_rate: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsCreativesResponse {
|
||||
data: AnalyticsCreativeData[];
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Common API Responses
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface ListResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SuccessResponse {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Query Parameters
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface TargetChannelQueryParams {
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface ExternalChannelQueryParams {
|
||||
target_channel_id?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface CreativeQueryParams {
|
||||
target_channel_id?: string;
|
||||
is_archived?: boolean;
|
||||
}
|
||||
|
||||
export interface PurchaseQueryParams {
|
||||
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";
|
||||
}
|
||||
|
||||
export interface AnalyticsOverviewQueryParams {
|
||||
target_channel_id?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
}
|
||||
|
||||
export interface AnalyticsCostsQueryParams {
|
||||
target_channel_id?: string;
|
||||
date_from: string; // required
|
||||
date_to: string; // required
|
||||
group_by: GroupByPeriod;
|
||||
}
|
||||
|
||||
export interface AnalyticsChannelsQueryParams {
|
||||
target_channel_id?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
type: "external" | "target";
|
||||
}
|
||||
|
||||
export interface AnalyticsCreativesQueryParams {
|
||||
target_channel_id?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
}
|
||||
21
lib/types/common.ts
Normal file
21
lib/types/common.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// ============================================================================
|
||||
// Common Types
|
||||
// ============================================================================
|
||||
|
||||
export type LoadingState = "idle" | "loading" | "success" | "error";
|
||||
|
||||
export interface AsyncState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface SortParams {
|
||||
sort_by?: string;
|
||||
order?: "asc" | "desc";
|
||||
}
|
||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
102
lib/utils/constants.ts
Normal file
102
lib/utils/constants.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
export const API_URL =
|
||||
process.env.NEXT_PUBLIC_API_URL || "https://api.tgex.app/v1";
|
||||
export const USE_MOCKS = process.env.NEXT_PUBLIC_USE_MOCKS === "true";
|
||||
export const BOT_USERNAME = process.env.NEXT_PUBLIC_BOT_USERNAME || "tgex_bot";
|
||||
|
||||
// Local Storage Keys
|
||||
export const STORAGE_KEYS = {
|
||||
ACCESS_TOKEN: "tgex_access_token",
|
||||
REFRESH_TOKEN: "tgex_refresh_token",
|
||||
USER: "tgex_user",
|
||||
} as const;
|
||||
|
||||
// API Endpoints
|
||||
export const API_ENDPOINTS = {
|
||||
// Auth
|
||||
AUTH_INIT: "/auth/init",
|
||||
AUTH_COMPLETE: "/auth/complete",
|
||||
AUTH_REFRESH: "/auth/refresh",
|
||||
AUTH_ME: "/auth/me",
|
||||
|
||||
// Target Channels
|
||||
TARGET_CHANNELS: "/target-channels",
|
||||
TARGET_CHANNEL: (id: string) => `/target-channels/${id}`,
|
||||
|
||||
// External Channels
|
||||
EXTERNAL_CHANNELS: "/external-channels",
|
||||
EXTERNAL_CHANNEL: (id: string) => `/external-channels/${id}`,
|
||||
EXTERNAL_CHANNELS_IMPORT: "/external-channels/import",
|
||||
|
||||
// Creatives
|
||||
CREATIVES: "/creatives",
|
||||
CREATIVE: (id: string) => `/creatives/${id}`,
|
||||
|
||||
// Purchases
|
||||
PURCHASES: "/purchases",
|
||||
PURCHASE: (id: string) => `/purchases/${id}`,
|
||||
PURCHASE_REFRESH_VIEWS: (id: string) => `/purchases/${id}/refresh-views`,
|
||||
|
||||
// Analytics
|
||||
ANALYTICS_OVERVIEW: "/analytics/overview",
|
||||
ANALYTICS_COSTS: "/analytics/costs",
|
||||
ANALYTICS_CHANNELS: "/analytics/channels",
|
||||
ANALYTICS_CREATIVES: "/analytics/creatives",
|
||||
} as const;
|
||||
|
||||
// Error Codes
|
||||
export const ERROR_CODES = {
|
||||
UNAUTHORIZED: "UNAUTHORIZED",
|
||||
FORBIDDEN: "FORBIDDEN",
|
||||
NOT_FOUND: "NOT_FOUND",
|
||||
VALIDATION_ERROR: "VALIDATION_ERROR",
|
||||
INTERNAL_ERROR: "INTERNAL_ERROR",
|
||||
CHANNEL_NOT_FOUND: "CHANNEL_NOT_FOUND",
|
||||
CHANNEL_ALREADY_EXISTS: "CHANNEL_ALREADY_EXISTS",
|
||||
INSUFFICIENT_PERMISSIONS: "INSUFFICIENT_PERMISSIONS",
|
||||
CREATIVE_IN_USE: "CREATIVE_IN_USE",
|
||||
INVALID_INVITE_LINK: "INVALID_INVITE_LINK",
|
||||
} as const;
|
||||
|
||||
// Date Formats
|
||||
export const DATE_FORMATS = {
|
||||
DISPLAY: "dd.MM.yyyy",
|
||||
DISPLAY_WITH_TIME: "dd.MM.yyyy HH:mm",
|
||||
API: "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
|
||||
} as const;
|
||||
|
||||
// Pagination
|
||||
export const DEFAULT_PAGE_SIZE = 20;
|
||||
export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
|
||||
// Routes
|
||||
export const ROUTES = {
|
||||
HOME: "/",
|
||||
LOGIN: "/login",
|
||||
AUTH_COMPLETE: "/auth-complete",
|
||||
|
||||
CHANNELS: "/channels",
|
||||
CHANNEL_DETAIL: (id: string) => `/channels/${id}`,
|
||||
|
||||
EXTERNAL_CHANNELS: "/external-channels",
|
||||
EXTERNAL_CHANNEL_DETAIL: (id: string) => `/external-channels/${id}`,
|
||||
EXTERNAL_CHANNELS_IMPORT: "/external-channels/import",
|
||||
|
||||
CREATIVES: "/creatives",
|
||||
CREATIVE_NEW: "/creatives/new",
|
||||
CREATIVE_DETAIL: (id: string) => `/creatives/${id}`,
|
||||
CREATIVE_EDIT: (id: string) => `/creatives/${id}/edit`,
|
||||
|
||||
PURCHASES: "/purchases",
|
||||
PURCHASE_NEW: "/purchases/new",
|
||||
PURCHASE_DETAIL: (id: string) => `/purchases/${id}`,
|
||||
PURCHASE_EDIT: (id: string) => `/purchases/${id}/edit`,
|
||||
|
||||
ANALYTICS: "/analytics",
|
||||
ANALYTICS_COSTS: "/analytics/costs",
|
||||
ANALYTICS_CHANNELS: "/analytics/channels",
|
||||
ANALYTICS_CREATIVES: "/analytics/creatives",
|
||||
} as const;
|
||||
164
lib/utils/format.ts
Normal file
164
lib/utils/format.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
// ============================================================================
|
||||
// Formatting Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Форматирование числа с разделением тысяч
|
||||
*/
|
||||
export const formatNumber = (
|
||||
value: number | null | undefined,
|
||||
showSign: boolean = false
|
||||
): string => {
|
||||
if (value === null || value === undefined) return "—";
|
||||
const sign = showSign && value > 0 ? "+" : "";
|
||||
return `${sign}${new Intl.NumberFormat("ru-RU").format(value)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Форматирование валюты (рубли)
|
||||
*/
|
||||
export const formatCurrency = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "—";
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Форматирование CPF/CPM с двумя знаками после запятой
|
||||
*/
|
||||
export const formatMetric = (value: number | null | undefined): string => {
|
||||
if (value === null || value === undefined) return "—";
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Форматирование процента
|
||||
*/
|
||||
export const formatPercent = (
|
||||
value: number | null | undefined,
|
||||
showSign: boolean = false
|
||||
): string => {
|
||||
if (value === null || value === undefined) return "—";
|
||||
const sign = showSign && value > 0 ? "+" : "";
|
||||
return `${sign}${formatMetric(value)}%`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Форматирование даты
|
||||
*/
|
||||
export const formatDate = (
|
||||
date: string | Date | null | undefined,
|
||||
includeTime: boolean = false
|
||||
): string => {
|
||||
if (!date) return "—";
|
||||
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
|
||||
if (includeTime) {
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(d);
|
||||
};
|
||||
|
||||
/**
|
||||
* Форматирование относительной даты (сколько прошло)
|
||||
*/
|
||||
export const formatRelativeDate = (date: string | Date): string => {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 7) {
|
||||
return formatDate(d);
|
||||
} else if (days > 0) {
|
||||
return `${days} ${pluralize(days, "день", "дня", "дней")} назад`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours} ${pluralize(hours, "час", "часа", "часов")} назад`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes} ${pluralize(
|
||||
minutes,
|
||||
"минута",
|
||||
"минуты",
|
||||
"минут"
|
||||
)} назад`;
|
||||
} else {
|
||||
return "только что";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Плюрализация русских слов
|
||||
*/
|
||||
export const pluralize = (
|
||||
count: number,
|
||||
one: string,
|
||||
few: string,
|
||||
many: string
|
||||
): string => {
|
||||
const mod10 = count % 10;
|
||||
const mod100 = count % 100;
|
||||
|
||||
if (mod10 === 1 && mod100 !== 11) {
|
||||
return one;
|
||||
} else if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) {
|
||||
return few;
|
||||
} else {
|
||||
return many;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Сокращение больших чисел (1000 -> 1K)
|
||||
*/
|
||||
export const formatCompactNumber = (
|
||||
value: number | null | undefined
|
||||
): string => {
|
||||
if (value === null || value === undefined) return "—";
|
||||
|
||||
if (value >= 1000000) {
|
||||
return `${(value / 1000000).toFixed(1)}M`;
|
||||
} else if (value >= 1000) {
|
||||
return `${(value / 1000).toFixed(1)}K`;
|
||||
}
|
||||
|
||||
return formatNumber(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Форматирование username (с @)
|
||||
*/
|
||||
export const formatUsername = (username: string | null | undefined): string => {
|
||||
if (!username) return "—";
|
||||
return username.startsWith("@") ? username : `@${username}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Обрезка длинного текста
|
||||
*/
|
||||
export const truncate = (text: string, maxLength: number = 50): string => {
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.substring(0, maxLength)}...`;
|
||||
};
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
61
package.json
Normal file
61
package.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "16.0.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.0",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "19.2.0",
|
||||
"react-hook-form": "^7.66.0",
|
||||
"recharts": "2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.1",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
5762
pnpm-lock.yaml
generated
Normal file
5762
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
231
project.md
Normal file
231
project.md
Normal file
@@ -0,0 +1,231 @@
|
||||
# **Общее описание системы**
|
||||
|
||||
Система для владельцев каналов позволяющая удобно систематизировать информацию о закупках рекламы и предоставляющая удобный интерфейс для анализа проведенных рекламных компаний
|
||||
|
||||
# **Термины и определения**
|
||||
|
||||
«Целевой канал» — канал, в который мы приводим подписчиков. Им владеет пользователь (владелец проекта) и именно для него считаем подписки.
|
||||
|
||||
«Внешний канал» — сторонний канал, в котором покупаем размещение рекламы выбранного креатива. Пользователю он **не принадлежит**, бот туда **не добавляется**.
|
||||
|
||||
«Креатив» — сущность/шаблон текста сообщения, **привязанная к целевому каналу**.
|
||||
|
||||
«Закуп» — факт размещения креатива во **внешнем канале**. Для каждого закупа создаётся отдельная invite-ссылка в целевой канал.
|
||||
|
||||
CPF — Cost Per Follower — метрика отражающая стоимость привлечения одного подписчика
|
||||
|
||||
CPM — Cost Per Mile — метрика отражающая стоимость 1000 просмотров
|
||||
|
||||
# **Функционал**
|
||||
|
||||
## **1\. Авторизация/регистрация через телеграмм**
|
||||
|
||||
Пользователь в веб\-панели нажимает «Войти через Telegram». Его перенаправляет к боту по ссылке вида `https://t.me/example_bot?start=login`. Бот возвращает одноразовую ссылку вида `https://app.example.com/auth/complete?token=<one_time_login_token>`. Сайт обменивает токен на сессию. Если пользователя не существовало — создаётся новый профиль; если существовал — выполняется вход.
|
||||
|
||||
### **Сценарии использования**
|
||||
|
||||
**Новый пользователь**
|
||||
|
||||
1. Пользователь жмёт «Войти через Telegram» на сайте.
|
||||
2. Редирект в Telegram к боту: `/?start=login`.
|
||||
3. Создается новый пользователь с данными из события (Telegram ID, username, ФИО)
|
||||
4. Бот генерирует одноразовый токен для пользователя
|
||||
5. Бот отправляет кнопку «Войти» со ссылкой вида `/auth/complete?token=....`
|
||||
6. Пользователь переходит по ссылке и авторизуется.
|
||||
|
||||
**Существующий пользователь**
|
||||
|
||||
1. Пользователь жмёт «Войти через Telegram».
|
||||
2. `/?start?login` → бот генерирует ссылку вида `/auth/complete?token=....`
|
||||
3. Пользователь переходит по ссылке и авторизуется.
|
||||
|
||||
## **2\. Подключение целевых каналов**
|
||||
|
||||
Пользователь вручную добавляет бота в свой Telegram-канал с правами **администратора** (минимум: право создавать инвайт-ссылки и видеть вступления). Бот получает событие, валидирует, что канал новый/принадлежит пользователю, и регистрирует канал в системе. В веб\-панели доступен список подключённых каналов.
|
||||
|
||||
### **Сценарии использования**
|
||||
|
||||
**Подключение нового канала**
|
||||
|
||||
1. Пользователь в веб\-панели жмёт «Подключить канал» → видит инструкцию «Добавьте бота в нужный канал с правами администратора».
|
||||
2. Пользователь добавляет бота в канал.
|
||||
3. Бот получает событие о добавлении в канал
|
||||
4. Бот создаёт/обновляет запись канала, привязывая её к пользователю.
|
||||
5. Канал отображается в списке каналов пользователя
|
||||
**Ошибки (кратко):** нет прав администратора → канал не регистрируется; канал уже подключён → пропускаем, возвращаем «уже в системе».
|
||||
|
||||
**Просмотр (и отключение) каналов**
|
||||
|
||||
1. Пользователь открывает раздел «Каналы».
|
||||
2. Система показывает список: название, @username, ссылка
|
||||
3. Пользователь при необходимости жмёт «Отключить» → система помечает канал как отключённый (и предлагает вручную снять бота из админов в самом канале).
|
||||
**Результат:** список актуален; отключённые каналы не участвуют в аналитике и создании инвайт-ссылок.
|
||||
|
||||
## **3\. Каталог внешних каналов**
|
||||
|
||||
### **Описание**
|
||||
|
||||
Пользователь ведёт собственный список **внешних каналов** (куда планирует покупать размещения) для целевого канала.
|
||||
|
||||
Список можно **редактировать вручную** и **импортировать из Tgstat (Excel)**.
|
||||
|
||||
Каждый внешний канал может быть привязан **к одному или нескольким целевым каналам**. При добавлении внешнего канала пользователь указывает, к каким целевым каналам его привязать.
|
||||
|
||||
### **Основные действия доступные для каталога каналов**
|
||||
|
||||
* Просмотр списка внешних каналов для выбранного целевого канала.
|
||||
* Добавление/удаление внешнего канала.
|
||||
* Импорт из Excel (tgstat) с валидацией и дедупликацией.
|
||||
|
||||
### **Требования к данным**
|
||||
|
||||
Внешний канал и Целевой канал связаны через M2M связь
|
||||
|
||||
## **4\. Креативы**
|
||||
|
||||
Креатив — это шаблон рекламного сообщения, используемого при закупке рекламы во внешних каналах, содержащий переменную, куда нужно вставить ссылку на целевой канал.
|
||||
|
||||
Каждый креатив связан с **одним целевым каналом** и может быть использован в нескольких закупах.
|
||||
|
||||
На этапе первой версии поддерживается только **текстовый формат** без медиафайлов.
|
||||
|
||||
### **Сценарии использования**
|
||||
|
||||
**Создание нового креатива**
|
||||
|
||||
1. Пользователь открывает раздел «Креативы» и нажимает «Создать креатив».
|
||||
2. Указывает:
|
||||
* Название креатива (для внутреннего использования),
|
||||
* Текст сообщения,
|
||||
* Целевой канал, к которому креатив относится.
|
||||
3. Сохраняет запись.
|
||||
4. Креатив появляется в списке и доступен для выбора при создании закупа.
|
||||
|
||||
**Результат:** создан новый креатив, связанный с выбранным целевым каналом.
|
||||
|
||||
**Просмотр и редактирование креативов**
|
||||
|
||||
1. Пользователь открывает список креативов.
|
||||
2. В таблице видны: название, целевой канал, дата создания и количество связанных закупов.
|
||||
3. При необходимости пользователь может:
|
||||
* открыть креатив,
|
||||
* изменить текст,
|
||||
* переименовать,
|
||||
* архивировать
|
||||
* удалить (если не используется в активных закупах).
|
||||
|
||||
**Результат:** список актуальных креативов; изменения сохраняются без потери связей с закупами.
|
||||
|
||||
## **5\. Закупы**
|
||||
|
||||
### **Описание**
|
||||
|
||||
**Закуп** — это факт размещения выбранного креатива во внешнем канале.
|
||||
|
||||
Для каждого закупа создаётся **уникальная пригласительная ссылка** в целевой канал, по которой отслеживаются подписки.
|
||||
|
||||
Закуп связывает между собой три сущности:
|
||||
|
||||
* целевой канал (куда ведём трафик),
|
||||
* внешний канал (где размещаем рекламу),
|
||||
* креатив (что публикуем).
|
||||
|
||||
Пользователь может создавать, редактировать и просматривать закупы, а также видеть их основные показатели эффективности
|
||||
|
||||
### **Сценарии использования**
|
||||
|
||||
**Создание нового закупа**
|
||||
|
||||
1. Пользователь открывает раздел **«Закупы»** и нажимает «Создать закуп».
|
||||
2. Указывает:
|
||||
* **Целевой канал**,
|
||||
* **Внешний канал**,
|
||||
* **Креатив**,
|
||||
* Дату размещения (планируемую или фактическую),
|
||||
* Стоимость закупа (опционально),
|
||||
* Комментарий,
|
||||
* Ссылка на рекламное сообщение во внешнем канале (для получение информации о просмотрах).
|
||||
* Тип ссылки (открытая/с одобрением ботом)
|
||||
3. После сохранения система создаёт **уникальную публичную или приватную invite-ссылку** (в зависимости от настройки закупа “***тип ссылки***” п.2) в целевой канал через Telegram Bot API.
|
||||
4. Закуп появляется в списке с возможностью скопировать текст для рекламного сообщений и доступен для просмотра аналитики.
|
||||
|
||||
**Результат:** создан новый закуп с уникальной ссылкой для отслеживания переходов и подписок.
|
||||
|
||||
**Просмотр и обновление закупов**
|
||||
|
||||
1. Пользователь открывает список закупов (таблица).
|
||||
2. В таблице отображаются: целевой канал, внешний канал, креатив, дата, стоимость, количество подписок.
|
||||
3. Пользователь может:
|
||||
* изменить данные закупа (например, обновить стоимость, комментарий или дату),
|
||||
* удалить закуп (с предупреждением о невозможности восстановления данных),
|
||||
* архивировать закуп (убрать из статистики и аналитики)
|
||||
* перейти к просмотру аналитики по конкретному закупу.
|
||||
|
||||
## **6\. Обработка события подписки пользователя в целевой канал через Telegram Bot API**
|
||||
|
||||
Бот — администратор целевого канала. При вступлении пользователя Telegram шлёт одно из двух событий (`ChatMemberUpdate`, `ChatJoinRequest`) по которым мы **однозначно маппим подписку к закупу**
|
||||
|
||||
### **Сценарии использования**
|
||||
|
||||
**Подписка по открытой ссылке**
|
||||
|
||||
1. Для закупа создана уникальная invite\_link.
|
||||
2. Пользователь переходит и сразу вступает в канал.
|
||||
3. Бот получает событие `ChatMemberUpdated`
|
||||
4. По `invite_link` находим закуп и сохраняем информацию о подписке
|
||||
5. Инкрементируем метрики закупа (подписки, конверсия).
|
||||
|
||||
**Результат:** подписка учтена и привязана к конкретному закупу.
|
||||
|
||||
**Запрос на вступление в канла**
|
||||
|
||||
1. Для закупа создана уникальная `private_invite_link`.
|
||||
2. Пользователь переходит по ссылке и отправляет `ChatJoinRequest`.
|
||||
3. Бот получает событие `ChatJoinRequest`
|
||||
4. По `invite_link` находим закуп и сохраняем информацию о подписке
|
||||
5. Инкрементируем метрики закупа (подписки, конверсия).
|
||||
|
||||
**Результат:** подписка учтена и привязана к конкретному закупу.
|
||||
|
||||
## **7\. Получение информации о кол-ве просмотров через Telegram API**
|
||||
|
||||
### **Описание**
|
||||
|
||||
Система должна уметь подтягивать **число просмотров** рекламного поста по **ссылке на сообщение** внешнего канала.
|
||||
|
||||
Технически корректный способ — через **Telegram API (или MTProto/TDLib)**: для публичных каналов можно читать сообщения и их поле views без добавления бота в канал.
|
||||
|
||||
Поддерживаемые форматы ссылок:
|
||||
|
||||
* https://t.me/\<username\>/\<message\_id\>
|
||||
* https://t.me/c/\<internal\_chat\_id\>/\<message\_id\> (для приватных каналов с доступом — см. ограничения ниже)
|
||||
|
||||
Метрика хранится как снимки (time series) с периодическим опросом (раз в час), чтобы видеть динамику добора просмотров.
|
||||
|
||||
💡Тут надо подумать, как частно нужно получать эти данные и как их надо агрегировать, так как у телеграмма как-то заковыристо считается и показывается кол-во просмотров
|
||||
|
||||
### **Сценарии использования**
|
||||
|
||||
**Успешное получение просмотров (публичный канал)**
|
||||
|
||||
1. В карточке закупа пользователь добавляет ссылку на пост (t.me/\<username\>/\<message\_id\>).
|
||||
2. Бэкенд через TDLib/MTProto вызывает channels.getMessages и читает поле views.
|
||||
3. Сохраняет снэпшот: views=12345, fetched\_at=....
|
||||
4. Планировщик повторяет опрос по расписанию, строится кривая добора просмотров.
|
||||
**Результат:** в аналитике закупа и сводках отображается актуальный счётчик просмотров и стоимость подписчика с учётом просмотров (пока — только CPS/CPF, просмотры — справочно).
|
||||
|
||||
**Недоступно (приватный канал / нет прав / битая ссылка)**
|
||||
|
||||
1. Пользователь добавляет ссылку t.me/c/.../123.
|
||||
2. Запрос через TDLib возвращает «нет доступа» или «не найдено».
|
||||
3. Система помечает источник как unavailable и предлагает:
|
||||
* добавить доступ (пригласить учётку чтения), **или**
|
||||
* указать просмотры вручную, **или**
|
||||
* загрузить из отчёта Tgstat/экселя.
|
||||
**Результат:** закуп помечен как недоступный для авто-сбора; допускается ручной ввод/импорт.
|
||||
|
||||
## **8\. Просмотр аналитики по целевым каналам, внешним каналам, креативам и закупам**
|
||||
|
||||
* Отображать средний CPF в списках закупов, креативов и внешних каналов
|
||||
* Отображать средний CPM в списках закупов, креативов и внешних каналов
|
||||
* Отображать столбчатую диаграмму с объемом закупок с группировкой по дате (день, неделя, месяц, квартал, год) и возможностью фильтрации по целевым каналам на отдельном экране “Аналитика” → “Затраты”
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user