diff --git a/.cursor/rules/frontend-rules.mdc b/.cursor/rules/frontend-rules.mdc new file mode 100644 index 0000000..042dc10 --- /dev/null +++ b/.cursor/rules/frontend-rules.mdc @@ -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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md new file mode 100644 index 0000000..16f823c --- /dev/null +++ b/API_DOCUMENTATION.md @@ -0,0 +1,739 @@ +# API Документация + +## Базовый URL + +``` +https://api.tgex.app/v1 +``` + +## Аутентификация + +Все запросы (кроме `/auth/*`) требуют JWT токен в заголовке: + +``` +Authorization: Bearer +``` + +--- + +## 1. Аутентификация + +### POST `/auth/init` + +Инициализация процесса авторизации через Telegram + +**Response:** + +```typescript +{ + bot_username: string; + start_param: string; // "login" +} +``` + +### POST `/auth/complete` + +Завершение авторизации с одноразовым токеном + +**Request:** + +```typescript +{ + token: string; // one-time token from bot +} +``` + +**Response:** + +```typescript +{ + access_token: string; + refresh_token: string; + user: User; +} +``` + +### POST `/auth/refresh` + +Обновление access токена + +**Request:** + +```typescript +{ + refresh_token: string; +} +``` + +**Response:** + +```typescript +{ + access_token: string; +} +``` + +### GET `/auth/me` + +Получение информации о текущем пользователе + +**Response:** + +```typescript +User; +``` + +--- + +## 2. Типы данных + +### User + +```typescript +{ + id: string; + telegram_id: number; + username: string | null; + first_name: string; + last_name: string | null; + created_at: string; // ISO 8601 +} +``` + +### TargetChannel + +```typescript +{ + id: string; + telegram_id: number; + title: string; + username: string | null; // @channel_name + description: string | null; + is_active: boolean; + created_at: string; + updated_at: string; + // Stats + total_purchases: number; + total_subscriptions: number; + avg_cpf: number | null; // Average Cost Per Follower +} +``` + +### ExternalChannel + +```typescript +{ + id: string; + telegram_id: number | null; + title: string; + username: string | null; + link: string; // t.me link or custom + subscribers_count: number | null; + description: string | null; + created_at: string; + updated_at: string; + // Relations + target_channels: string[]; // IDs of target channels + // Stats + total_purchases: number; + avg_cpf: number | null; + avg_cpm: number | null; +} +``` + +### Creative + +```typescript +{ + id: string; + name: string; + text: string; // Template with {link} placeholder + target_channel_id: string; + is_archived: boolean; + created_at: string; + updated_at: string; + // Relations + target_channel: TargetChannel; + // Stats + total_purchases: number; + total_subscriptions: number; + avg_cpf: number | null; +} +``` + +### Purchase + +```typescript +{ + id: string; + target_channel_id: string; + external_channel_id: string; + creative_id: string; + // Purchase details + scheduled_date: string | null; // ISO 8601 + actual_date: string | null; + cost: number | null; // in rubles + comment: string | null; + post_link: string | null; // Link to ad post in external channel + invite_link: string; // Generated invite link + invite_link_type: "public" | "private"; // with bot approval or not + is_archived: boolean; + created_at: string; + updated_at: string; + // Relations + target_channel: TargetChannel; + external_channel: ExternalChannel; + creative: Creative; + // Stats + subscriptions_count: number; + views_count: number | null; + cpf: number | null; // Cost Per Follower + cpm: number | null; // Cost Per Mile (1000 views) + conversion_rate: number | null; // subscriptions / views * 100 +} +``` + +### Subscription + +```typescript +{ + id: string; + purchase_id: string; + telegram_user_id: number; + username: string | null; + joined_at: string; // ISO 8601 + event_type: "chat_member" | "join_request"; +} +``` + +### ViewsSnapshot + +```typescript +{ + id: string; + purchase_id: string; + views: number; + fetched_at: string; // ISO 8601 +} +``` + +--- + +## 3. Целевые каналы + +### GET `/target-channels` + +Получение списка целевых каналов + +**Query params:** + +- `is_active?: boolean` - фильтр по активности + +**Response:** + +```typescript +{ + data: TargetChannel[]; + total: number; +} +``` + +### GET `/target-channels/:id` + +Получение детальной информации о канале + +**Response:** + +```typescript +TargetChannel & { + recent_purchases: Purchase[]; // last 5 + stats_by_period: { + period: 'day' | 'week' | 'month'; + subscriptions: number; + cost: number; + cpf: number; + }[]; +} +``` + +### PATCH `/target-channels/:id` + +Обновление канала (только is_active) + +**Request:** + +```typescript +{ + is_active: boolean; +} +``` + +**Response:** + +```typescript +TargetChannel; +``` + +### DELETE `/target-channels/:id` + +Отключение канала (soft delete) + +**Response:** + +```typescript +{ + success: true; +} +``` + +--- + +## 4. Внешние каналы + +### GET `/external-channels` + +Получение списка внешних каналов + +**Query params:** + +- `target_channel_id?: string` - фильтр по целевому каналу +- `search?: string` - поиск по названию/username + +**Response:** + +```typescript +{ + data: ExternalChannel[]; + total: number; +} +``` + +### GET `/external-channels/:id` + +Получение детальной информации + +**Response:** + +```typescript +ExternalChannel & { + purchases: Purchase[]; +} +``` + +### POST `/external-channels` + +Создание нового внешнего канала + +**Request:** + +```typescript +{ + title: string; + username?: string; + link: string; + subscribers_count?: number; + description?: string; + target_channel_ids: string[]; // Привязка к целевым каналам +} +``` + +**Response:** + +```typescript +ExternalChannel; +``` + +### PATCH `/external-channels/:id` + +Обновление внешнего канала + +**Request:** + +```typescript +Partial<{ + title: string; + username: string; + link: string; + subscribers_count: number; + description: string; + target_channel_ids: string[]; +}>; +``` + +**Response:** + +```typescript +ExternalChannel; +``` + +### DELETE `/external-channels/:id` + +Удаление внешнего канала + +**Response:** + +```typescript +{ + success: true; +} +``` + +### POST `/external-channels/import` + +Импорт из Excel (Tgstat) + +**Request:** + +``` +Content-Type: multipart/form-data +file: File (Excel) +target_channel_id: string +``` + +**Response:** + +```typescript +{ + imported: number; + skipped: number; + errors: string[]; + channels: ExternalChannel[]; +} +``` + +--- + +## 5. Креативы + +### GET `/creatives` + +Получение списка креативов + +**Query params:** + +- `target_channel_id?: string` - фильтр по целевому каналу +- `is_archived?: boolean` - фильтр по архивности + +**Response:** + +```typescript +{ + data: Creative[]; + total: number; +} +``` + +### GET `/creatives/:id` + +Получение детальной информации + +**Response:** + +```typescript +Creative & { + purchases: Purchase[]; +} +``` + +### POST `/creatives` + +Создание нового креатива + +**Request:** + +```typescript +{ + name: string; + text: string; // Must contain {link} placeholder + target_channel_id: string; +} +``` + +**Response:** + +```typescript +Creative; +``` + +### PATCH `/creatives/:id` + +Обновление креатива + +**Request:** + +```typescript +Partial<{ + name: string; + text: string; + is_archived: boolean; +}>; +``` + +**Response:** + +```typescript +Creative; +``` + +### DELETE `/creatives/:id` + +Удаление креатива (только если нет активных закупов) + +**Response:** + +```typescript +{ + success: true; +} +``` + +--- + +## 6. Закупы + +### GET `/purchases` + +Получение списка закупов + +**Query params:** + +- `target_channel_id?: string` +- `external_channel_id?: string` +- `creative_id?: string` +- `is_archived?: boolean` +- `date_from?: string` (ISO 8601) +- `date_to?: string` (ISO 8601) +- `sort?: 'date' | 'cost' | 'cpf' | 'subscriptions'` +- `order?: 'asc' | 'desc'` + +**Response:** + +```typescript +{ + data: Purchase[]; + total: number; +} +``` + +### GET `/purchases/:id` + +Получение детальной информации о закупе + +**Response:** + +```typescript +Purchase & { + subscriptions: Subscription[]; + views_history: ViewsSnapshot[]; +} +``` + +### POST `/purchases` + +Создание нового закупа + +**Request:** + +```typescript +{ + target_channel_id: string; + external_channel_id: string; + creative_id: string; + scheduled_date?: string; + actual_date?: string; + cost?: number; + comment?: string; + post_link?: string; + invite_link_type: 'public' | 'private'; +} +``` + +**Response:** + +```typescript +Purchase & { + formatted_message: string; // Creative text with invite_link inserted +} +``` + +### PATCH `/purchases/:id` + +Обновление закупа + +**Request:** + +```typescript +Partial<{ + scheduled_date: string; + actual_date: string; + cost: number; + comment: string; + post_link: string; + is_archived: boolean; +}>; +``` + +**Response:** + +```typescript +Purchase; +``` + +### DELETE `/purchases/:id` + +Удаление закупа + +**Response:** + +```typescript +{ + success: true; +} +``` + +### POST `/purchases/:id/refresh-views` + +Принудительное обновление просмотров + +**Response:** + +```typescript +{ + views: number; + fetched_at: string; +} +``` + +--- + +## 7. Аналитика + +### GET `/analytics/overview` + +Общая статистика + +**Query params:** + +- `target_channel_id?: string` +- `date_from?: string` +- `date_to?: string` + +**Response:** + +```typescript +{ + total_purchases: number; + total_cost: number; + total_subscriptions: number; + avg_cpf: number; + avg_cpm: number; + avg_conversion_rate: number; +} +``` + +### GET `/analytics/costs` + +Данные для графика затрат + +**Query params:** + +- `target_channel_id?: string` +- `date_from: string` (required) +- `date_to: string` (required) +- `group_by: 'day' | 'week' | 'month' | 'quarter' | 'year'` + +**Response:** + +```typescript +{ + data: { + period: string; // ISO 8601 date (start of period) + cost: number; + purchases_count: number; + subscriptions: number; + } + []; +} +``` + +### GET `/analytics/channels` + +Аналитика по каналам + +**Query params:** + +- `target_channel_id?: string` +- `date_from?: string` +- `date_to?: string` +- `type: 'external' | 'target'` + +**Response:** + +```typescript +{ + data: { + channel_id: string; + channel_name: string; + purchases_count: number; + total_cost: number; + total_subscriptions: number; + avg_cpf: number; + avg_cpm: number; + } + []; +} +``` + +### GET `/analytics/creatives` + +Аналитика по креативам + +**Query params:** + +- `target_channel_id?: string` +- `date_from?: string` +- `date_to?: string` + +**Response:** + +```typescript +{ + data: { + creative_id: string; + creative_name: string; + purchases_count: number; + total_cost: number; + total_subscriptions: number; + avg_cpf: number; + conversion_rate: number; + } + []; +} +``` + +--- + +## 8. Коды ошибок + +```typescript +{ + error: { + code: string; + message: string; + details?: any; + } +} +``` + +Основные коды: + +- `UNAUTHORIZED` - 401 +- `FORBIDDEN` - 403 +- `NOT_FOUND` - 404 +- `VALIDATION_ERROR` - 422 +- `INTERNAL_ERROR` - 500 +- `CHANNEL_NOT_FOUND` - 404 +- `CHANNEL_ALREADY_EXISTS` - 409 +- `INSUFFICIENT_PERMISSIONS` - 403 +- `CREATIVE_IN_USE` - 409 +- `INVALID_INVITE_LINK` - 422 diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..933a5c9 --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -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 diff --git a/README.md b/README.md index e69de29..e215bc4 100644 --- a/README.md +++ b/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. diff --git a/app/(auth)/auth-complete/page.tsx b/app/(auth)/auth-complete/page.tsx new file mode 100644 index 0000000..12016f9 --- /dev/null +++ b/app/(auth)/auth-complete/page.tsx @@ -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(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 ( +
+ + +
+ {status === "loading" && ( + + )} + {status === "success" && ( + + )} + {status === "error" && ( + + )} +
+ + + {status === "loading" && "Авторизация..."} + {status === "success" && "Успешно!"} + {status === "error" && "Ошибка"} + + + + {status === "loading" && "Пожалуйста, подождите"} + {status === "success" && "Перенаправляем в приложение..."} + {status === "error" && error} + +
+ + {status === "error" && ( + + + + {error} + + + + + )} +
+
+ ); +} + +export default function AuthCompletePage() { + return ( + + + + } + > + + + ); +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..d25e623 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -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(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 ( +
+ + +
+ + TG + +
+ TGEX + + Система управления рекламными закупками в Telegram + +
+ + + {displayError && ( + + + {displayError} + + )} + + {USE_MOCKS && ( +
+ + +
+
+ +
+
+ + или + +
+
+
+ )} + + + + {USE_MOCKS && ( + + + + Режим разработки: Используются моки данных. + Бекенд не требуется. + + + )} + +

+ Войдя в систему, вы соглашаетесь с условиями использования +

+
+
+
+ ); +} diff --git a/app/(dashboard)/analytics/costs/page.tsx b/app/(dashboard)/analytics/costs/page.tsx new file mode 100644 index 0000000..91764b4 --- /dev/null +++ b/app/(dashboard)/analytics/costs/page.tsx @@ -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(null); + const [channels, setChannels] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [period, setPeriod] = useState<"day" | "week" | "month">("week"); + const [targetChannelId, setTargetChannelId] = useState( + 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 ( + <> + +
+
+
+

+ Затраты на рекламу +

+

+ Динамика расходов по периодам +

+
+
+ + {error && ( + + + {error} + + )} + + + + Фильтры + + +
+
+ + +
+ +
+ + +
+
+
+
+ + {loading ? ( + + + + + + ) : report ? ( + <> +
+ + + + Всего затрат + + + + +
+ {formatCurrency(report.totalCost)} +
+

+ За выбранный период +

+
+
+ + + + + Средние затраты + + + +
+ {formatCurrency(report.averageCost)} +
+

+ В среднем за период +

+
+
+ + + + + Периодов + + + +
+ {formatNumber(report.periods.length)} +
+

+ С активностью +

+
+
+
+ + + + График затрат + Динамика расходов на рекламу + + + + + + + `₽${formatNumber(value)}`} + /> + [ + `₽${formatNumber(value)}`, + "Затраты", + ]} + /> + + + + + + + + + + Детализация по периодам + Затраты и количество закупов + + +
+ {report.periods.map((period) => ( +
+
+

{period.date}

+

+ {formatNumber(period.purchases_count)} закупов +

+
+
+

+ {formatCurrency(period.total_cost)} +

+

+ ср. ₽{formatNumber(period.avg_cost)} +

+
+
+ ))} +
+
+
+ + ) : null} +
+ + ); +} diff --git a/app/(dashboard)/analytics/page.tsx b/app/(dashboard)/analytics/page.tsx new file mode 100644 index 0000000..133a58a --- /dev/null +++ b/app/(dashboard)/analytics/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + <> + +
+ +
+ + ); + } + + if (error || !overview) { + return ( + <> + +
+ + + + {error || "Не удалось загрузить аналитику"} + + +
+ + ); + } + + return ( + <> + +
+
+

Аналитика

+

+ Сводная статистика за последний месяц +

+
+ +
+ + + + Всего закупов + + + + +
+ {formatNumber(overview.totalPurchases)} +
+
+ {overview.purchasesChange >= 0 ? ( + + ) : ( + + )} + = 0 + ? "text-green-600" + : "text-red-600" + } + > + {overview.purchasesChange >= 0 ? "+" : ""} + {formatNumber(overview.purchasesChange)} + + + за прошлый месяц + +
+
+
+ + + + + Всего подписчиков + + + + +
+ {formatNumber(overview.totalFollowers)} +
+
+ {overview.followersChange >= 0 ? ( + + ) : ( + + )} + = 0 + ? "text-green-600" + : "text-red-600" + } + > + {overview.followersChange >= 0 ? "+" : ""} + {formatNumber(overview.followersChange)} + + + за прошлый месяц + +
+
+
+ + + + Средний CPF + + + +
+ ₽{formatMetric(overview.averageCpf)} +
+
+ {overview.cpfChange <= 0 ? ( + + ) : ( + + )} + + {formatPercent(overview.cpfChange, true)} + + + от прошлого месяца + +
+
+
+ + + + Средний CPM + + + +
+ ₽{formatMetric(overview.averageCpm)} +
+
+ {overview.cpmChange <= 0 ? ( + + ) : ( + + )} + + {formatPercent(overview.cpmChange, true)} + + + от прошлого месяца + +
+
+
+
+ +
+ + + Топ каналов по CPF + + Самые эффективные внешние каналы + + + +
+ {overview.topChannelsByCpf?.length > 0 ? ( + overview.topChannelsByCpf.slice(0, 5).map((item, index) => ( +
+ + {index + 1} + +
+

+ {item.channel_name} +

+

+ {formatNumber(item.total_purchases)} закупов +

+
+
+

+ ₽{formatMetric(item.avg_cpf)} +

+

CPF

+
+
+ )) + ) : ( +

+ Нет данных +

+ )} +
+
+
+ + + + Топ креативов + + Самые эффективные рекламные креативы + + + +
+ {overview.topCreativesByCpf?.length > 0 ? ( + overview.topCreativesByCpf.slice(0, 5).map((item, index) => ( +
+ + {index + 1} + +
+

+ {item.creative_name} +

+

+ {formatNumber(item.total_subscriptions)} подписчиков +

+
+
+

+ ₽{formatMetric(item.avg_cpf)} +

+

CPF

+
+
+ )) + ) : ( +

+ Нет данных +

+ )} +
+
+
+
+
+ + ); +} diff --git a/app/(dashboard)/channels/[id]/page.tsx b/app/(dashboard)/channels/[id]/page.tsx new file mode 100644 index 0000000..e78dc13 --- /dev/null +++ b/app/(dashboard)/channels/[id]/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + <> + +
+ +
+ + ); + } + + if (error || !channel) { + return ( + <> + +
+ + + {error || "Канал не найден"} + + +
+ + ); + } + + return ( + <> + +
+
+
+
+ +

+ {channel.title} +

+ + {channel.is_active ? "Активен" : "Отключен"} + +
+
+ {channel.username ? ( + + {formatUsername(channel.username)} + + + ) : ( + Приватный канал + )} + + Добавлен {formatDate(channel.created_at)} +
+
+
+ + +
+
+ + {channel.description && ( + + + Описание + + +

{channel.description}

+
+
+ )} + +
+ + + + Всего закупов + + + + +
+ {formatNumber(channel.total_purchases)} +
+

+ За весь период +

+
+
+ + + + + Подписчиков привлечено + + + + +
+ {formatNumber(channel.total_subscriptions)} +
+

Всего

+
+
+ + + + Средний CPF + + + +
+ ₽{formatMetric(channel.avg_cpf)} +
+

+ Стоимость подписчика +

+
+
+
+ + + + Статистика по периодам + Последние закупы + + + + + + Статистика по периодам + + Динамика привлечения подписчиков + + + + + + + Период + Подписчики + Затраты + CPF + + + + {channel.stats_by_period.map((stat, index) => ( + + + {stat.period} + + + {formatNumber(stat.subscriptions)} + + + {formatCurrency(stat.cost)} + + + ₽{formatMetric(stat.cpf)} + + + ))} + +
+
+
+
+ + + + + Последние закупы + + 5 последних рекламных размещений + + + + {channel.recent_purchases.length === 0 ? ( +

+ Закупов пока нет +

+ ) : ( + + + + Внешний канал + Дата + Подписчики + CPF + + + + + {channel.recent_purchases.map((purchase) => ( + + + {purchase.external_channel.title} + + + {formatDate( + purchase.actual_date || purchase.scheduled_date + )} + + + {formatNumber(purchase.subscriptions_count)} + + + ₽{formatMetric(purchase.cpf)} + + + + + + ))} + +
+ )} +
+
+
+
+
+ + ); +} diff --git a/app/(dashboard)/channels/page.tsx b/app/(dashboard)/channels/page.tsx new file mode 100644 index 0000000..9fbd14e --- /dev/null +++ b/app/(dashboard)/channels/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + <> + +
+
+
+

+ Целевые каналы +

+

+ Каналы, в которые вы приводите подписчиков +

+
+
+ + + +
+
+ Подключение нового канала + + Добавьте бота в свой Telegram-канал с правами администратора + +
+ +
+
+ +
+
    +
  1. Откройте свой Telegram-канал
  2. +
  3. Добавьте @tgex_bot в администраторы канала
  4. +
  5. + Предоставьте боту права:{" "} + создавать инвайт-ссылки и{" "} + видеть вступления +
  6. +
  7. Канал автоматически появится в списке ниже
  8. +
+
+
+
+ + {error && ( + + + {error} + + )} + + setActiveTab(v as any)} + className="w-full" + > + + Все ({channels.length}) + + Активные ({channels.filter((c) => c.is_active).length}) + + + Отключенные ({channels.filter((c) => !c.is_active).length}) + + + + + {loading ? ( +
+ +
+ ) : filteredChannels.length === 0 ? ( + + + +

+ {activeTab === "all" && "Нет подключенных каналов"} + {activeTab === "active" && "Нет активных каналов"} + {activeTab === "inactive" && "Нет отключенных каналов"} +

+

+ Добавьте бота в свой канал, чтобы начать работу +

+
+
+ ) : ( +
+ {filteredChannels.map((channel) => ( + + +
+
+ + + {channel.title} + + + {channel.username ? ( + + {formatUsername(channel.username)} + + ) : ( + + Приватный канал + + )} + +
+ + {channel.is_active ? "Активен" : "Отключен"} + +
+
+ + {channel.description && ( +

+ {channel.description} +

+ )} + +
+
+
+ {formatNumber(channel.total_purchases)} +
+
+ Закупов +
+
+
+
+ {formatNumber(channel.total_subscriptions)} +
+
+ Подписчиков +
+
+
+
+ ₽{formatMetric(channel.avg_cpf)} +
+
+ CPF +
+
+
+ +
+ + +
+
+
+ ))} +
+ )} +
+
+
+ + ); +} diff --git a/app/(dashboard)/creatives/[id]/page.tsx b/app/(dashboard)/creatives/[id]/page.tsx new file mode 100644 index 0000000..8d91c5f --- /dev/null +++ b/app/(dashboard)/creatives/[id]/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + <> + +
+ +
+ + ); + } + + if (error || !creative) { + return ( + <> + +
+ + + {error || "Креатив не найден"} + + +
+ + ); + } + + const previewText = creative.text.replace( + "{link}", + "https://t.me/+InviteLinkExample" + ); + + return ( + <> + +
+
+
+
+ +

+ {creative.name} +

+ {creative.is_archived && Архив} +
+
+ Целевой канал: {creative.target_channel.title} +
+
+
+ + +
+
+ +
+ + + + Всего закупов + + + + +
+ {formatNumber(creative.total_purchases)} +
+

+ С этим креативом +

+
+
+ + + + + Подписчиков привлечено + + + + +
+ {formatNumber(creative.total_subscriptions)} +
+

Всего

+
+
+ + + + Средний CPF + + + +
+ ₽{formatMetric(creative.avg_cpf)} +
+

+ По всем закупам +

+
+
+
+ +
+ + + Текст креатива + Шаблон с переменной {"{link}"} + + +
+
+                  {creative.text}
+                
+
+
+
+ + + + Предпросмотр + + С подставленной пригласительной ссылкой + + + +
+

{previewText}

+
+
+
+
+ + + + Закупы с этим креативом + История использования креатива + + + {creative.purchases.length === 0 ? ( +

+ Закупов с этим креативом пока нет +

+ ) : ( + + + + Внешний канал + Дата + Стоимость + Подписчики + CPF + + + + + {creative.purchases.map((purchase) => ( + + + {purchase.external_channel.title} + + + {formatDate( + purchase.actual_date || purchase.scheduled_date + )} + + + {formatCurrency(purchase.cost)} + + + {formatNumber(purchase.subscriptions_count)} + + + ₽{formatMetric(purchase.cpf)} + + + + + + ))} + +
+ )} +
+
+
+ + ); +} diff --git a/app/(dashboard)/creatives/new/page.tsx b/app/(dashboard)/creatives/new/page.tsx new file mode 100644 index 0000000..18e8ed2 --- /dev/null +++ b/app/(dashboard)/creatives/new/page.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 ( + <> + +
+
+
+

+ Создать креатив +

+

+ Новый шаблон рекламного сообщения +

+
+ +
+ + {error && ( + + + {error} + + )} + +
+ + + Основная информация + + Название и целевой канал креатива + + + +
+ + + setFormData({ ...formData, name: e.target.value }) + } + placeholder='Например: Креатив "Присоединяйся"' + /> +
+ +
+ + +
+
+
+ + + + Текст сообщения + + Используйте переменную {"{link}"} для вставки ссылки + + + +
+ +