mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
@@ -20,5 +20,5 @@ VITE_APP_LOGO=V
|
||||
# ===== RUNTIME VARIABLES =====
|
||||
|
||||
# Port to expose the frontend on host machine
|
||||
# Default: 3000 (http://localhost:3000)
|
||||
CABINET_PORT=3000
|
||||
# Default: 3020 (http://localhost:3020)
|
||||
CABINET_PORT=3020
|
||||
|
||||
@@ -33,6 +33,7 @@ FROM nginx:alpine
|
||||
|
||||
# Copy built assets from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
37
README.md
37
README.md
@@ -1,6 +1,6 @@
|
||||
# Bedolaga Cabinet - Web Interface
|
||||
|
||||
Современный веб-интерфейс личного кабинета для VPN бота на базе [Remnawave Bedolaga Telegram Bot](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot).
|
||||
Современный веб-интерфейс личного кабинета для VPN бота на базе [Remnawave Bedolaga Telegram Bot V3.0.0+](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot).
|
||||
|
||||
## Возможности
|
||||
|
||||
@@ -70,7 +70,7 @@ VITE_APP_NAME=My VPN Cabinet
|
||||
VITE_APP_LOGO=V
|
||||
|
||||
# Порт для Docker контейнера
|
||||
CABINET_PORT=3000
|
||||
CABINET_PORT=3020
|
||||
```
|
||||
|
||||
#### 3. Запуск в Docker
|
||||
@@ -101,6 +101,7 @@ CABINET_ALLOWED_ORIGINS=http://localhost:3000,https://cabinet.yourdomain.com
|
||||
## Настройка прокси для production
|
||||
|
||||
Frontend - это статические файлы (HTML, JS, CSS). Для работы нужно:
|
||||
|
||||
1. Раздавать статику через веб-сервер
|
||||
2. Проксировать `/api/*` запросы на backend бота
|
||||
|
||||
@@ -153,8 +154,8 @@ services:
|
||||
- ./cabinet-dist:/srv/cabinet:ro
|
||||
- caddy_data:/data
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- '80:80'
|
||||
- '443:443'
|
||||
networks:
|
||||
- bot_network
|
||||
```
|
||||
@@ -164,6 +165,7 @@ services:
|
||||
Если хотите использовать готовый Docker контейнер с nginx внутри.
|
||||
|
||||
**⚠️ Важно:**
|
||||
|
||||
- Порт `80` в примерах - это **внутренний порт контейнера** (nginx внутри), не хост-порт!
|
||||
- Контейнеры должны быть в одной Docker сети для связи друг с другом
|
||||
- Имена контейнеров используются как DNS внутри Docker сети
|
||||
@@ -194,11 +196,12 @@ services:
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
external: true # Используем существующую сеть
|
||||
name: remnawave-bedolaga-telegram-bot_bot_network # Пример для bedolaga bot
|
||||
external: true # Используем существующую сеть
|
||||
name: remnawave-bedolaga-telegram-bot_bot_network # Пример для bedolaga bot
|
||||
```
|
||||
|
||||
**Важно:** Замените имя сети на вашу:
|
||||
|
||||
- Если у вас bot + caddy: используйте сеть бота (обычно `<название_проекта>_bot_network`)
|
||||
- Если отдельный Caddy: узнайте через `docker network ls`
|
||||
- Если используете Traefik: обычно `traefik` или `web`
|
||||
@@ -212,6 +215,7 @@ docker compose up -d
|
||||
**Шаг 4:** Добавьте в конфигурацию Caddy/Nginx:
|
||||
|
||||
Caddy проксирует на контейнер:
|
||||
|
||||
```caddyfile
|
||||
cabinet.yourdomain.com {
|
||||
# API на backend
|
||||
@@ -228,6 +232,7 @@ cabinet.yourdomain.com {
|
||||
```
|
||||
|
||||
Nginx (добавьте в существующий конфиг):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
@@ -269,6 +274,7 @@ docker exec <nginx_container> nginx -s reload
|
||||
#### B. Если Caddy/Nginx ещё НЕ запущен:
|
||||
|
||||
docker-compose.yml:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
cabinet-frontend:
|
||||
@@ -292,18 +298,18 @@ networks:
|
||||
|
||||
### Build-time (используются при сборке)
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|------------|----------|--------------|
|
||||
| `VITE_API_URL` | Путь к API (`/api` или полный URL) | `/api` |
|
||||
| `VITE_TELEGRAM_BOT_USERNAME` | Username Telegram бота (без @) | - |
|
||||
| `VITE_APP_NAME` | Название приложения | `Cabinet` |
|
||||
| `VITE_APP_LOGO` | Логотип (короткий текст) | `V` |
|
||||
| Переменная | Описание | По умолчанию |
|
||||
| ---------------------------- | ---------------------------------- | ------------ |
|
||||
| `VITE_API_URL` | Путь к API (`/api` или полный URL) | `/api` |
|
||||
| `VITE_TELEGRAM_BOT_USERNAME` | Username Telegram бота (без @) | - |
|
||||
| `VITE_APP_NAME` | Название приложения | `Cabinet` |
|
||||
| `VITE_APP_LOGO` | Логотип (короткий текст) | `V` |
|
||||
|
||||
### Runtime (только для Docker)
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|------------|----------|--------------|
|
||||
| `CABINET_PORT` | Порт контейнера | `3000` |
|
||||
| Переменная | Описание | По умолчанию |
|
||||
| -------------- | --------------- | ------------ |
|
||||
| `CABINET_PORT` | Порт контейнера | `3000` |
|
||||
|
||||
## Структура проекта
|
||||
|
||||
@@ -337,6 +343,7 @@ bedolaga-cabinet/
|
||||
### 502 Bad Gateway
|
||||
|
||||
Убедитесь что:
|
||||
|
||||
1. Backend бот запущен и работает
|
||||
2. Контейнеры находятся в одной Docker сети
|
||||
3. Имя сервиса backend в прокси конфигурации правильное
|
||||
|
||||
@@ -16,13 +16,21 @@ services:
|
||||
container_name: cabinet_frontend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env # Загрузить переменные из .env файла
|
||||
- .env # Загрузить переменные из .env файла
|
||||
ports:
|
||||
# Маппинг: <хост-порт>:<контейнер-порт>
|
||||
# Внутри контейнера nginx слушает на порту 80
|
||||
- "${CABINET_PORT:-3000}:80"
|
||||
- '${CABINET_PORT:-3020}:80'
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"]
|
||||
test:
|
||||
[
|
||||
'CMD',
|
||||
'wget',
|
||||
'--no-verbose',
|
||||
'--tries=1',
|
||||
'--spider',
|
||||
'http://localhost:80/',
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
|
||||
29
nginx.conf
Normal file
29
nginx.conf
Normal file
@@ -0,0 +1,29 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
||||
# API проксировать, если нужно
|
||||
location /api/ {
|
||||
try_files $uri /index.html;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Optional: кэширование статических файлов
|
||||
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg)$ {
|
||||
expires 1y;
|
||||
access_log off;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
}
|
||||
63
src/App.tsx
63
src/App.tsx
@@ -25,6 +25,13 @@ import AdminTariffs from './pages/AdminTariffs'
|
||||
import AdminServers from './pages/AdminServers'
|
||||
import AdminPanel from './pages/AdminPanel'
|
||||
import AdminDashboard from './pages/AdminDashboard'
|
||||
import AdminBroadcasts from './pages/AdminBroadcasts'
|
||||
import AdminPromocodes from './pages/AdminPromocodes'
|
||||
import AdminCampaigns from './pages/AdminCampaigns'
|
||||
import AdminUsers from './pages/AdminUsers'
|
||||
import AdminPayments from './pages/AdminPayments'
|
||||
import AdminPromoOffers from './pages/AdminPromoOffers'
|
||||
import AdminRemnawave from './pages/AdminRemnawave'
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuthStore()
|
||||
@@ -217,6 +224,62 @@ function App() {
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/broadcasts"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AdminBroadcasts />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/promocodes"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AdminPromocodes />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/campaigns"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AdminCampaigns />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AdminUsers />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/payments"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AdminPayments />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/promo-offers"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AdminPromoOffers />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/remnawave"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<AdminRemnawave />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Catch all */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
168
src/api/adminBroadcasts.ts
Normal file
168
src/api/adminBroadcasts.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import apiClient from './client'
|
||||
|
||||
// Types
|
||||
export interface BroadcastFilter {
|
||||
key: string
|
||||
label: string
|
||||
count: number | null
|
||||
group: string | null
|
||||
}
|
||||
|
||||
export interface TariffFilter {
|
||||
key: string
|
||||
label: string
|
||||
tariff_id: number
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface BroadcastFiltersResponse {
|
||||
filters: BroadcastFilter[]
|
||||
tariff_filters: TariffFilter[]
|
||||
custom_filters: BroadcastFilter[]
|
||||
}
|
||||
|
||||
export interface TariffForBroadcast {
|
||||
id: number
|
||||
name: string
|
||||
filter_key: string
|
||||
active_users_count: number
|
||||
}
|
||||
|
||||
export interface BroadcastTariffsResponse {
|
||||
tariffs: TariffForBroadcast[]
|
||||
}
|
||||
|
||||
export interface BroadcastButton {
|
||||
key: string
|
||||
label: string
|
||||
default: boolean
|
||||
}
|
||||
|
||||
export interface BroadcastButtonsResponse {
|
||||
buttons: BroadcastButton[]
|
||||
}
|
||||
|
||||
export interface BroadcastMedia {
|
||||
type: 'photo' | 'video' | 'document'
|
||||
file_id: string
|
||||
caption?: string
|
||||
}
|
||||
|
||||
export interface BroadcastCreateRequest {
|
||||
target: string
|
||||
message_text: string
|
||||
selected_buttons: string[]
|
||||
media?: BroadcastMedia
|
||||
}
|
||||
|
||||
export interface Broadcast {
|
||||
id: number
|
||||
target_type: string
|
||||
message_text: string
|
||||
has_media: boolean
|
||||
media_type: string | null
|
||||
media_file_id: string | null
|
||||
media_caption: string | null
|
||||
total_count: number
|
||||
sent_count: number
|
||||
failed_count: number
|
||||
status: 'queued' | 'in_progress' | 'completed' | 'partial' | 'failed' | 'cancelled' | 'cancelling'
|
||||
admin_id: number | null
|
||||
admin_name: string | null
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
progress_percent: number
|
||||
}
|
||||
|
||||
export interface BroadcastListResponse {
|
||||
items: Broadcast[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface BroadcastPreviewRequest {
|
||||
target: string
|
||||
}
|
||||
|
||||
export interface BroadcastPreviewResponse {
|
||||
target: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface MediaUploadResponse {
|
||||
media_type: string
|
||||
file_id: string
|
||||
file_unique_id: string | null
|
||||
media_url: string
|
||||
}
|
||||
|
||||
export const adminBroadcastsApi = {
|
||||
// Get all available filters with counts
|
||||
getFilters: async (): Promise<BroadcastFiltersResponse> => {
|
||||
const response = await apiClient.get<BroadcastFiltersResponse>('/cabinet/admin/broadcasts/filters')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get tariffs for filtering
|
||||
getTariffs: async (): Promise<BroadcastTariffsResponse> => {
|
||||
const response = await apiClient.get<BroadcastTariffsResponse>('/cabinet/admin/broadcasts/tariffs')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get available buttons
|
||||
getButtons: async (): Promise<BroadcastButtonsResponse> => {
|
||||
const response = await apiClient.get<BroadcastButtonsResponse>('/cabinet/admin/broadcasts/buttons')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Preview broadcast (get recipients count)
|
||||
preview: async (target: string): Promise<BroadcastPreviewResponse> => {
|
||||
const response = await apiClient.post<BroadcastPreviewResponse>('/cabinet/admin/broadcasts/preview', {
|
||||
target,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Create and start broadcast
|
||||
create: async (data: BroadcastCreateRequest): Promise<Broadcast> => {
|
||||
const response = await apiClient.post<Broadcast>('/cabinet/admin/broadcasts', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get list of broadcasts
|
||||
list: async (limit = 20, offset = 0): Promise<BroadcastListResponse> => {
|
||||
const response = await apiClient.get<BroadcastListResponse>('/cabinet/admin/broadcasts', {
|
||||
params: { limit, offset },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get broadcast details
|
||||
get: async (id: number): Promise<Broadcast> => {
|
||||
const response = await apiClient.get<Broadcast>(`/cabinet/admin/broadcasts/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Stop broadcast
|
||||
stop: async (id: number): Promise<Broadcast> => {
|
||||
const response = await apiClient.post<Broadcast>(`/cabinet/admin/broadcasts/${id}/stop`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Upload media (uses existing media endpoint)
|
||||
uploadMedia: async (file: File, mediaType: 'photo' | 'video' | 'document'): Promise<MediaUploadResponse> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('media_type', mediaType)
|
||||
|
||||
const response = await apiClient.post<MediaUploadResponse>('/cabinet/media/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default adminBroadcastsApi
|
||||
39
src/api/adminPayments.ts
Normal file
39
src/api/adminPayments.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import apiClient from './client'
|
||||
import type { PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types'
|
||||
|
||||
export interface PaymentsStats {
|
||||
total_pending: number
|
||||
by_method: Record<string, number>
|
||||
}
|
||||
|
||||
export const adminPaymentsApi = {
|
||||
// Get all pending payments (admin)
|
||||
getPendingPayments: async (params?: {
|
||||
page?: number
|
||||
per_page?: number
|
||||
method_filter?: string
|
||||
}): Promise<PaginatedResponse<PendingPayment>> => {
|
||||
const response = await apiClient.get<PaginatedResponse<PendingPayment>>('/cabinet/admin/payments', {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get payments statistics
|
||||
getStats: async (): Promise<PaymentsStats> => {
|
||||
const response = await apiClient.get<PaymentsStats>('/cabinet/admin/payments/stats')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get specific payment details
|
||||
getPayment: async (method: string, paymentId: number): Promise<PendingPayment> => {
|
||||
const response = await apiClient.get<PendingPayment>(`/cabinet/admin/payments/${method}/${paymentId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Manually check payment status
|
||||
checkPaymentStatus: async (method: string, paymentId: number): Promise<ManualCheckResponse> => {
|
||||
const response = await apiClient.post<ManualCheckResponse>(`/cabinet/admin/payments/${method}/${paymentId}/check`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
386
src/api/adminRemnawave.ts
Normal file
386
src/api/adminRemnawave.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
// ============ Types ============
|
||||
|
||||
// Status & Connection
|
||||
export interface ConnectionStatus {
|
||||
status: string
|
||||
message: string
|
||||
api_url?: string
|
||||
status_code?: number
|
||||
system_info?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface RemnaWaveStatusResponse {
|
||||
is_configured: boolean
|
||||
configuration_error?: string
|
||||
connection?: ConnectionStatus
|
||||
}
|
||||
|
||||
// System Statistics
|
||||
export interface SystemSummary {
|
||||
users_online: number
|
||||
total_users: number
|
||||
active_connections: number
|
||||
nodes_online: number
|
||||
users_last_day: number
|
||||
users_last_week: number
|
||||
users_never_online: number
|
||||
total_user_traffic: number
|
||||
}
|
||||
|
||||
export interface ServerInfo {
|
||||
cpu_cores: number
|
||||
cpu_physical_cores: number
|
||||
memory_total: number
|
||||
memory_used: number
|
||||
memory_free: number
|
||||
memory_available: number
|
||||
uptime_seconds: number
|
||||
}
|
||||
|
||||
export interface Bandwidth {
|
||||
realtime_download: number
|
||||
realtime_upload: number
|
||||
realtime_total: number
|
||||
}
|
||||
|
||||
export interface TrafficPeriod {
|
||||
current: number
|
||||
previous: number
|
||||
difference?: string
|
||||
}
|
||||
|
||||
export interface TrafficPeriods {
|
||||
last_2_days: TrafficPeriod
|
||||
last_7_days: TrafficPeriod
|
||||
last_30_days: TrafficPeriod
|
||||
current_month: TrafficPeriod
|
||||
current_year: TrafficPeriod
|
||||
}
|
||||
|
||||
export interface SystemStatsResponse {
|
||||
system: SystemSummary
|
||||
users_by_status: Record<string, number>
|
||||
server_info: ServerInfo
|
||||
bandwidth: Bandwidth
|
||||
traffic_periods: TrafficPeriods
|
||||
nodes_realtime: Record<string, unknown>[]
|
||||
nodes_weekly: Record<string, unknown>[]
|
||||
last_updated?: string
|
||||
}
|
||||
|
||||
// Nodes
|
||||
export interface NodeInfo {
|
||||
uuid: string
|
||||
name: string
|
||||
address: string
|
||||
country_code?: string
|
||||
is_connected: boolean
|
||||
is_disabled: boolean
|
||||
is_node_online: boolean
|
||||
is_xray_running: boolean
|
||||
users_online?: number
|
||||
traffic_used_bytes?: number
|
||||
traffic_limit_bytes?: number
|
||||
last_status_change?: string
|
||||
last_status_message?: string
|
||||
xray_uptime?: string
|
||||
is_traffic_tracking_active: boolean
|
||||
traffic_reset_day?: number
|
||||
notify_percent?: number
|
||||
consumption_multiplier: number
|
||||
cpu_count?: number
|
||||
cpu_model?: string
|
||||
total_ram?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
provider_uuid?: string
|
||||
}
|
||||
|
||||
export interface NodesListResponse {
|
||||
items: NodeInfo[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface NodesOverview {
|
||||
total: number
|
||||
online: number
|
||||
offline: number
|
||||
disabled: number
|
||||
total_users_online: number
|
||||
nodes: NodeInfo[]
|
||||
}
|
||||
|
||||
export interface NodeStatisticsResponse {
|
||||
node: NodeInfo
|
||||
realtime?: Record<string, unknown>
|
||||
usage_history: Record<string, unknown>[]
|
||||
last_updated?: string
|
||||
}
|
||||
|
||||
export interface NodeActionResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
is_disabled?: boolean
|
||||
}
|
||||
|
||||
// Squads
|
||||
export interface SquadWithLocalInfo {
|
||||
uuid: string
|
||||
name: string
|
||||
members_count: number
|
||||
inbounds_count: number
|
||||
inbounds: Record<string, unknown>[]
|
||||
local_id?: number
|
||||
display_name?: string
|
||||
country_code?: string
|
||||
is_available?: boolean
|
||||
is_trial_eligible?: boolean
|
||||
price_kopeks?: number
|
||||
max_users?: number
|
||||
current_users?: number
|
||||
is_synced: boolean
|
||||
}
|
||||
|
||||
export interface SquadsListResponse {
|
||||
items: SquadWithLocalInfo[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface SquadDetailResponse extends SquadWithLocalInfo {
|
||||
description?: string
|
||||
sort_order?: number
|
||||
active_subscriptions: number
|
||||
}
|
||||
|
||||
export interface SquadOperationResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// Migration
|
||||
export interface MigrationPreviewResponse {
|
||||
squad_uuid: string
|
||||
squad_name: string
|
||||
current_users: number
|
||||
max_users?: number
|
||||
users_to_migrate: number
|
||||
}
|
||||
|
||||
export interface MigrationStats {
|
||||
source_uuid: string
|
||||
target_uuid: string
|
||||
total: number
|
||||
updated: number
|
||||
panel_updated: number
|
||||
panel_failed: number
|
||||
source_removed: number
|
||||
target_added: number
|
||||
}
|
||||
|
||||
export interface MigrationResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
error?: string
|
||||
data?: MigrationStats
|
||||
}
|
||||
|
||||
// Inbounds
|
||||
export interface InboundsListResponse {
|
||||
items: Record<string, unknown>[]
|
||||
total: number
|
||||
}
|
||||
|
||||
// Auto Sync
|
||||
export interface AutoSyncStatus {
|
||||
enabled: boolean
|
||||
times: string[]
|
||||
next_run?: string
|
||||
is_running: boolean
|
||||
last_run_started_at?: string
|
||||
last_run_finished_at?: string
|
||||
last_run_success?: boolean
|
||||
last_run_reason?: string
|
||||
last_run_error?: string
|
||||
last_user_stats?: Record<string, unknown>
|
||||
last_server_stats?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface AutoSyncRunResponse {
|
||||
started: boolean
|
||||
success?: boolean
|
||||
error?: string
|
||||
user_stats?: Record<string, unknown>
|
||||
server_stats?: Record<string, unknown>
|
||||
reason?: string
|
||||
}
|
||||
|
||||
// Sync
|
||||
export interface SyncResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// ============ API ============
|
||||
|
||||
export const adminRemnawaveApi = {
|
||||
// Status & Connection
|
||||
getStatus: async (): Promise<RemnaWaveStatusResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/status')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// System Statistics
|
||||
getSystemStats: async (): Promise<SystemStatsResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/system')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Nodes
|
||||
getNodes: async (): Promise<NodesListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/nodes')
|
||||
return response.data
|
||||
},
|
||||
|
||||
getNodesOverview: async (): Promise<NodesOverview> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/nodes/overview')
|
||||
return response.data
|
||||
},
|
||||
|
||||
getNodesRealtime: async (): Promise<Record<string, unknown>[]> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/nodes/realtime')
|
||||
return response.data
|
||||
},
|
||||
|
||||
getNode: async (uuid: string): Promise<NodeInfo> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
getNodeStatistics: async (uuid: string): Promise<NodeStatisticsResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}/statistics`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
nodeAction: async (uuid: string, action: 'enable' | 'disable' | 'restart'): Promise<NodeActionResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/remnawave/nodes/${uuid}/action`, { action })
|
||||
return response.data
|
||||
},
|
||||
|
||||
restartAllNodes: async (): Promise<NodeActionResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/nodes/restart-all')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Squads
|
||||
getSquads: async (): Promise<SquadsListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/squads')
|
||||
return response.data
|
||||
},
|
||||
|
||||
getSquad: async (uuid: string): Promise<SquadDetailResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/remnawave/squads/${uuid}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createSquad: async (data: { name: string; inbound_uuids?: string[] }): Promise<SquadOperationResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/squads', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
updateSquad: async (uuid: string, data: { name?: string; inbound_uuids?: string[] }): Promise<SquadOperationResponse> => {
|
||||
const response = await apiClient.patch(`/cabinet/admin/remnawave/squads/${uuid}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deleteSquad: async (uuid: string): Promise<SquadOperationResponse> => {
|
||||
const response = await apiClient.delete(`/cabinet/admin/remnawave/squads/${uuid}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
squadAction: async (uuid: string, data: {
|
||||
action: 'add_all_users' | 'remove_all_users' | 'delete' | 'rename' | 'update_inbounds'
|
||||
name?: string
|
||||
inbound_uuids?: string[]
|
||||
}): Promise<SquadOperationResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/remnawave/squads/${uuid}/action`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Migration
|
||||
getMigrationPreview: async (uuid: string): Promise<MigrationPreviewResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/remnawave/squads/${uuid}/migration-preview`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
migrateSquad: async (sourceUuid: string, targetUuid: string): Promise<MigrationResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/squads/migrate', {
|
||||
source_uuid: sourceUuid,
|
||||
target_uuid: targetUuid,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Inbounds
|
||||
getInbounds: async (): Promise<InboundsListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/inbounds')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Auto Sync
|
||||
getAutoSyncStatus: async (): Promise<AutoSyncStatus> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/sync/auto/status')
|
||||
return response.data
|
||||
},
|
||||
|
||||
toggleAutoSync: async (enabled: boolean): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/toggle', { enabled })
|
||||
return response.data
|
||||
},
|
||||
|
||||
runAutoSync: async (): Promise<AutoSyncRunResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/run')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Manual Sync
|
||||
syncFromPanel: async (mode: 'all' | 'new_only' | 'update_only' = 'all'): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/from-panel', { mode })
|
||||
return response.data
|
||||
},
|
||||
|
||||
syncToPanel: async (): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/to-panel')
|
||||
return response.data
|
||||
},
|
||||
|
||||
syncServers: async (): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/servers')
|
||||
return response.data
|
||||
},
|
||||
|
||||
validateSubscriptions: async (): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/validate')
|
||||
return response.data
|
||||
},
|
||||
|
||||
cleanupSubscriptions: async (): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/cleanup')
|
||||
return response.data
|
||||
},
|
||||
|
||||
syncSubscriptionStatuses: async (): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/statuses')
|
||||
return response.data
|
||||
},
|
||||
|
||||
getSyncRecommendations: async (): Promise<SyncResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/sync/recommendations')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default adminRemnawaveApi
|
||||
418
src/api/adminUsers.ts
Normal file
418
src/api/adminUsers.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import apiClient from './client'
|
||||
|
||||
// ============ Types ============
|
||||
|
||||
export interface UserSubscriptionInfo {
|
||||
id: number
|
||||
status: string
|
||||
is_trial: boolean
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
traffic_limit_gb: number
|
||||
traffic_used_gb: number
|
||||
device_limit: number
|
||||
tariff_id: number | null
|
||||
tariff_name: string | null
|
||||
autopay_enabled: boolean
|
||||
is_active: boolean
|
||||
days_remaining: number
|
||||
}
|
||||
|
||||
export interface UserPromoGroupInfo {
|
||||
id: number
|
||||
name: string
|
||||
is_default: boolean
|
||||
}
|
||||
|
||||
export interface UserListItem {
|
||||
id: number
|
||||
telegram_id: number
|
||||
username: string | null
|
||||
first_name: string | null
|
||||
last_name: string | null
|
||||
full_name: string
|
||||
status: string
|
||||
balance_kopeks: number
|
||||
balance_rubles: number
|
||||
created_at: string
|
||||
last_activity: string | null
|
||||
has_subscription: boolean
|
||||
subscription_status: string | null
|
||||
subscription_is_trial: boolean
|
||||
subscription_end_date: string | null
|
||||
promo_group_id: number | null
|
||||
promo_group_name: string | null
|
||||
total_spent_kopeks: number
|
||||
purchase_count: number
|
||||
has_restrictions: boolean
|
||||
restriction_topup: boolean
|
||||
restriction_subscription: boolean
|
||||
}
|
||||
|
||||
export interface UsersListResponse {
|
||||
users: UserListItem[]
|
||||
total: number
|
||||
offset: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
export interface UserTransactionItem {
|
||||
id: number
|
||||
type: string
|
||||
amount_kopeks: number
|
||||
amount_rubles: number
|
||||
description: string | null
|
||||
payment_method: string | null
|
||||
is_completed: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface UserReferralInfo {
|
||||
referral_code: string
|
||||
referrals_count: number
|
||||
total_earnings_kopeks: number
|
||||
commission_percent: number | null
|
||||
referred_by_id: number | null
|
||||
referred_by_username: string | null
|
||||
}
|
||||
|
||||
export interface UserDetailResponse {
|
||||
id: number
|
||||
telegram_id: number
|
||||
username: string | null
|
||||
first_name: string | null
|
||||
last_name: string | null
|
||||
full_name: string
|
||||
status: string
|
||||
language: string
|
||||
balance_kopeks: number
|
||||
balance_rubles: number
|
||||
email: string | null
|
||||
email_verified: boolean
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
last_activity: string | null
|
||||
cabinet_last_login: string | null
|
||||
subscription: UserSubscriptionInfo | null
|
||||
promo_group: UserPromoGroupInfo | null
|
||||
referral: UserReferralInfo
|
||||
total_spent_kopeks: number
|
||||
purchase_count: number
|
||||
used_promocodes: number
|
||||
has_had_paid_subscription: boolean
|
||||
lifetime_used_traffic_bytes: number
|
||||
restriction_topup: boolean
|
||||
restriction_subscription: boolean
|
||||
restriction_reason: string | null
|
||||
promo_offer_discount_percent: number
|
||||
promo_offer_discount_source: string | null
|
||||
promo_offer_discount_expires_at: string | null
|
||||
recent_transactions: UserTransactionItem[]
|
||||
remnawave_uuid: string | null
|
||||
}
|
||||
|
||||
export interface UsersStatsResponse {
|
||||
total_users: number
|
||||
active_users: number
|
||||
blocked_users: number
|
||||
deleted_users: number
|
||||
new_today: number
|
||||
new_week: number
|
||||
new_month: number
|
||||
users_with_subscription: number
|
||||
users_with_active_subscription: number
|
||||
users_with_trial: number
|
||||
users_with_expired_subscription: number
|
||||
total_balance_kopeks: number
|
||||
total_balance_rubles: number
|
||||
avg_balance_kopeks: number
|
||||
active_today: number
|
||||
active_week: number
|
||||
active_month: number
|
||||
}
|
||||
|
||||
// Available tariffs
|
||||
export interface PeriodPriceInfo {
|
||||
days: number
|
||||
price_kopeks: number
|
||||
price_rubles: number
|
||||
}
|
||||
|
||||
export interface UserAvailableTariff {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
is_active: boolean
|
||||
is_trial_available: boolean
|
||||
traffic_limit_gb: number
|
||||
device_limit: number
|
||||
tier_level: number
|
||||
display_order: number
|
||||
period_prices: PeriodPriceInfo[]
|
||||
is_daily: boolean
|
||||
daily_price_kopeks: number
|
||||
custom_days_enabled: boolean
|
||||
price_per_day_kopeks: number
|
||||
min_days: number
|
||||
max_days: number
|
||||
is_available: boolean
|
||||
requires_promo_group: boolean
|
||||
}
|
||||
|
||||
export interface UserAvailableTariffsResponse {
|
||||
user_id: number
|
||||
promo_group_id: number | null
|
||||
promo_group_name: string | null
|
||||
tariffs: UserAvailableTariff[]
|
||||
total: number
|
||||
current_tariff_id: number | null
|
||||
current_tariff_name: string | null
|
||||
}
|
||||
|
||||
// Sync types
|
||||
export interface PanelUserInfo {
|
||||
uuid: string | null
|
||||
short_uuid: string | null
|
||||
username: string | null
|
||||
status: string | null
|
||||
expire_at: string | null
|
||||
traffic_limit_gb: number
|
||||
traffic_used_gb: number
|
||||
device_limit: number
|
||||
subscription_url: string | null
|
||||
active_squads: string[]
|
||||
}
|
||||
|
||||
export interface SyncFromPanelResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
panel_user: PanelUserInfo | null
|
||||
changes: Record<string, unknown>
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface SyncToPanelResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
action: string
|
||||
panel_uuid: string | null
|
||||
changes: Record<string, unknown>
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface PanelSyncStatusResponse {
|
||||
user_id: number
|
||||
telegram_id: number
|
||||
remnawave_uuid: string | null
|
||||
last_sync: string | null
|
||||
bot_subscription_status: string | null
|
||||
bot_subscription_end_date: string | null
|
||||
bot_traffic_limit_gb: number
|
||||
bot_traffic_used_gb: number
|
||||
bot_device_limit: number
|
||||
bot_squads: string[]
|
||||
panel_found: boolean
|
||||
panel_status: string | null
|
||||
panel_expire_at: string | null
|
||||
panel_traffic_limit_gb: number
|
||||
panel_traffic_used_gb: number
|
||||
panel_device_limit: number
|
||||
panel_squads: string[]
|
||||
has_differences: boolean
|
||||
differences: string[]
|
||||
}
|
||||
|
||||
// Update types
|
||||
export interface UpdateBalanceRequest {
|
||||
amount_kopeks: number
|
||||
description?: string
|
||||
create_transaction?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateBalanceResponse {
|
||||
success: boolean
|
||||
old_balance_kopeks: number
|
||||
new_balance_kopeks: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface UpdateSubscriptionRequest {
|
||||
action: 'extend' | 'set_end_date' | 'change_tariff' | 'set_traffic' | 'toggle_autopay' | 'cancel' | 'activate' | 'create'
|
||||
days?: number
|
||||
end_date?: string
|
||||
tariff_id?: number
|
||||
traffic_limit_gb?: number
|
||||
traffic_used_gb?: number
|
||||
autopay_enabled?: boolean
|
||||
is_trial?: boolean
|
||||
device_limit?: number
|
||||
}
|
||||
|
||||
export interface UpdateSubscriptionResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
subscription: UserSubscriptionInfo | null
|
||||
}
|
||||
|
||||
export interface UpdateUserStatusResponse {
|
||||
success: boolean
|
||||
old_status: string
|
||||
new_status: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface UpdateRestrictionsRequest {
|
||||
restriction_topup?: boolean
|
||||
restriction_subscription?: boolean
|
||||
restriction_reason?: string
|
||||
}
|
||||
|
||||
export interface UpdateRestrictionsResponse {
|
||||
success: boolean
|
||||
restriction_topup: boolean
|
||||
restriction_subscription: boolean
|
||||
restriction_reason: string | null
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface SyncFromPanelRequest {
|
||||
update_subscription?: boolean
|
||||
update_traffic?: boolean
|
||||
create_if_missing?: boolean
|
||||
}
|
||||
|
||||
export interface SyncToPanelRequest {
|
||||
create_if_missing?: boolean
|
||||
update_status?: boolean
|
||||
update_traffic_limit?: boolean
|
||||
update_expire_date?: boolean
|
||||
update_squads?: boolean
|
||||
}
|
||||
|
||||
// ============ API ============
|
||||
|
||||
export const adminUsersApi = {
|
||||
// List users
|
||||
getUsers: async (params: {
|
||||
offset?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
status?: 'active' | 'blocked' | 'deleted'
|
||||
sort_by?: 'created_at' | 'balance' | 'traffic' | 'last_activity' | 'total_spent' | 'purchase_count'
|
||||
} = {}): Promise<UsersListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/users', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get users stats
|
||||
getStats: async (): Promise<UsersStatsResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/users/stats')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get user detail
|
||||
getUser: async (userId: number): Promise<UserDetailResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get user by telegram ID
|
||||
getUserByTelegram: async (telegramId: number): Promise<UserDetailResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/users/by-telegram/${telegramId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get available tariffs for user
|
||||
getAvailableTariffs: async (userId: number, includeInactive = false): Promise<UserAvailableTariffsResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/available-tariffs`, {
|
||||
params: { include_inactive: includeInactive }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Update balance
|
||||
updateBalance: async (userId: number, data: UpdateBalanceRequest): Promise<UpdateBalanceResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/balance`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Update subscription
|
||||
updateSubscription: async (userId: number, data: UpdateSubscriptionRequest): Promise<UpdateSubscriptionResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/subscription`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Update status
|
||||
updateStatus: async (userId: number, status: 'active' | 'blocked' | 'deleted', reason?: string): Promise<UpdateUserStatusResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/status`, { status, reason })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Block user
|
||||
blockUser: async (userId: number, reason?: string): Promise<UpdateUserStatusResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/block`, null, { params: { reason } })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Unblock user
|
||||
unblockUser: async (userId: number): Promise<UpdateUserStatusResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/unblock`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Update restrictions
|
||||
updateRestrictions: async (userId: number, data: UpdateRestrictionsRequest): Promise<UpdateRestrictionsResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/restrictions`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Update promo group
|
||||
updatePromoGroup: async (userId: number, promoGroupId: number | null): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/promo-group`, { promo_group_id: promoGroupId })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Delete user
|
||||
deleteUser: async (userId: number, softDelete = true, reason?: string): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.delete(`/cabinet/admin/users/${userId}`, {
|
||||
data: { soft_delete: softDelete, reason }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get referrals
|
||||
getReferrals: async (userId: number, offset = 0, limit = 50): Promise<UsersListResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/referrals`, {
|
||||
params: { offset, limit }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get transactions
|
||||
getTransactions: async (userId: number, params: {
|
||||
offset?: number
|
||||
limit?: number
|
||||
transaction_type?: string
|
||||
} = {}): Promise<{ transactions: UserTransactionItem[]; total: number; offset: number; limit: number }> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/transactions`, { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Sync status
|
||||
getSyncStatus: async (userId: number): Promise<PanelSyncStatusResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/sync/status`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Sync from panel
|
||||
syncFromPanel: async (userId: number, data: SyncFromPanelRequest = {}): Promise<SyncFromPanelResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/from-panel`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Sync to panel
|
||||
syncToPanel: async (userId: number, data: SyncToPanelRequest = {}): Promise<SyncToPanelResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/to-panel`, data)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import apiClient from './client'
|
||||
import type { Balance, Transaction, PaymentMethod, PaginatedResponse } from '../types'
|
||||
import apiClient from './client'
|
||||
import type { Balance, Transaction, PaymentMethod, PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types'
|
||||
|
||||
export const balanceApi = {
|
||||
// Get current balance
|
||||
@@ -73,5 +73,28 @@ export const balanceApi = {
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get pending payments for manual verification
|
||||
getPendingPayments: async (params?: {
|
||||
page?: number
|
||||
per_page?: number
|
||||
}): Promise<PaginatedResponse<PendingPayment>> => {
|
||||
const response = await apiClient.get<PaginatedResponse<PendingPayment>>('/cabinet/balance/pending-payments', {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get specific pending payment details
|
||||
getPendingPayment: async (method: string, paymentId: number): Promise<PendingPayment> => {
|
||||
const response = await apiClient.get<PendingPayment>(`/cabinet/balance/pending-payments/${method}/${paymentId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Manually check payment status
|
||||
checkPaymentStatus: async (method: string, paymentId: number): Promise<ManualCheckResponse> => {
|
||||
const response = await apiClient.post<ManualCheckResponse>(`/cabinet/balance/pending-payments/${method}/${paymentId}/check`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
230
src/api/campaigns.ts
Normal file
230
src/api/campaigns.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import apiClient from './client'
|
||||
|
||||
// Types
|
||||
export type CampaignBonusType = 'balance' | 'subscription' | 'none' | 'tariff'
|
||||
|
||||
export interface TariffInfo {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface CampaignListItem {
|
||||
id: number
|
||||
name: string
|
||||
start_parameter: string
|
||||
bonus_type: CampaignBonusType
|
||||
is_active: boolean
|
||||
registrations_count: number
|
||||
total_revenue_kopeks: number
|
||||
conversion_rate: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CampaignListResponse {
|
||||
campaigns: CampaignListItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface CampaignDetail {
|
||||
id: number
|
||||
name: string
|
||||
start_parameter: string
|
||||
bonus_type: CampaignBonusType
|
||||
is_active: boolean
|
||||
balance_bonus_kopeks: number
|
||||
balance_bonus_rubles: number
|
||||
subscription_duration_days: number | null
|
||||
subscription_traffic_gb: number | null
|
||||
subscription_device_limit: number | null
|
||||
subscription_squads: string[]
|
||||
tariff_id: number | null
|
||||
tariff_duration_days: number | null
|
||||
tariff: TariffInfo | null
|
||||
created_by: number | null
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
deep_link: string | null
|
||||
}
|
||||
|
||||
export interface CampaignCreateRequest {
|
||||
name: string
|
||||
start_parameter: string
|
||||
bonus_type: CampaignBonusType
|
||||
is_active?: boolean
|
||||
balance_bonus_kopeks?: number
|
||||
subscription_duration_days?: number
|
||||
subscription_traffic_gb?: number
|
||||
subscription_device_limit?: number
|
||||
subscription_squads?: string[]
|
||||
tariff_id?: number
|
||||
tariff_duration_days?: number
|
||||
}
|
||||
|
||||
export interface CampaignUpdateRequest {
|
||||
name?: string
|
||||
start_parameter?: string
|
||||
bonus_type?: CampaignBonusType
|
||||
is_active?: boolean
|
||||
balance_bonus_kopeks?: number
|
||||
subscription_duration_days?: number
|
||||
subscription_traffic_gb?: number
|
||||
subscription_device_limit?: number
|
||||
subscription_squads?: string[]
|
||||
tariff_id?: number
|
||||
tariff_duration_days?: number
|
||||
}
|
||||
|
||||
export interface CampaignToggleResponse {
|
||||
id: number
|
||||
is_active: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface CampaignStatistics {
|
||||
id: number
|
||||
name: string
|
||||
start_parameter: string
|
||||
bonus_type: CampaignBonusType
|
||||
is_active: boolean
|
||||
registrations: number
|
||||
balance_issued_kopeks: number
|
||||
balance_issued_rubles: number
|
||||
subscription_issued: number
|
||||
last_registration: string | null
|
||||
total_revenue_kopeks: number
|
||||
total_revenue_rubles: number
|
||||
avg_revenue_per_user_kopeks: number
|
||||
avg_revenue_per_user_rubles: number
|
||||
avg_first_payment_kopeks: number
|
||||
avg_first_payment_rubles: number
|
||||
trial_users_count: number
|
||||
active_trials_count: number
|
||||
conversion_count: number
|
||||
paid_users_count: number
|
||||
conversion_rate: number
|
||||
trial_conversion_rate: number
|
||||
deep_link: string | null
|
||||
}
|
||||
|
||||
export interface CampaignRegistrationItem {
|
||||
id: number
|
||||
user_id: number
|
||||
telegram_id: number
|
||||
username: string | null
|
||||
first_name: string | null
|
||||
bonus_type: string
|
||||
balance_bonus_kopeks: number
|
||||
subscription_duration_days: number | null
|
||||
tariff_id: number | null
|
||||
tariff_duration_days: number | null
|
||||
created_at: string
|
||||
user_balance_kopeks: number
|
||||
has_subscription: boolean
|
||||
has_paid: boolean
|
||||
}
|
||||
|
||||
export interface CampaignRegistrationsResponse {
|
||||
registrations: CampaignRegistrationItem[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
}
|
||||
|
||||
export interface CampaignsOverview {
|
||||
total: number
|
||||
active: number
|
||||
inactive: number
|
||||
total_registrations: number
|
||||
total_balance_issued_kopeks: number
|
||||
total_balance_issued_rubles: number
|
||||
total_subscription_issued: number
|
||||
total_tariff_issued: number
|
||||
}
|
||||
|
||||
export interface ServerSquadInfo {
|
||||
id: number
|
||||
squad_uuid: string
|
||||
display_name: string
|
||||
country_code: string | null
|
||||
}
|
||||
|
||||
export interface TariffListItem {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
is_active: boolean
|
||||
traffic_limit_gb: number
|
||||
device_limit: number
|
||||
}
|
||||
|
||||
export const campaignsApi = {
|
||||
// Get campaigns overview
|
||||
getOverview: async (): Promise<CampaignsOverview> => {
|
||||
const response = await apiClient.get('/cabinet/admin/campaigns/overview')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get all campaigns
|
||||
getCampaigns: async (includeInactive = true, offset = 0, limit = 50): Promise<CampaignListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/campaigns', {
|
||||
params: { include_inactive: includeInactive, offset, limit }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get single campaign
|
||||
getCampaign: async (campaignId: number): Promise<CampaignDetail> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get campaign statistics
|
||||
getCampaignStats: async (campaignId: number): Promise<CampaignStatistics> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/stats`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get campaign registrations
|
||||
getCampaignRegistrations: async (campaignId: number, page = 1, perPage = 50): Promise<CampaignRegistrationsResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/registrations`, {
|
||||
params: { page, per_page: perPage }
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Create campaign
|
||||
createCampaign: async (data: CampaignCreateRequest): Promise<CampaignDetail> => {
|
||||
const response = await apiClient.post('/cabinet/admin/campaigns', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Update campaign
|
||||
updateCampaign: async (campaignId: number, data: CampaignUpdateRequest): Promise<CampaignDetail> => {
|
||||
const response = await apiClient.put(`/cabinet/admin/campaigns/${campaignId}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Delete campaign
|
||||
deleteCampaign: async (campaignId: number): Promise<{ message: string }> => {
|
||||
const response = await apiClient.delete(`/cabinet/admin/campaigns/${campaignId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Toggle campaign active status
|
||||
toggleCampaign: async (campaignId: number): Promise<CampaignToggleResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/campaigns/${campaignId}/toggle`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get available servers for subscription bonus
|
||||
getAvailableServers: async (): Promise<ServerSquadInfo[]> => {
|
||||
const response = await apiClient.get('/cabinet/admin/campaigns/available-servers')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get available tariffs for tariff bonus
|
||||
getAvailableTariffs: async (): Promise<TariffListItem[]> => {
|
||||
const response = await apiClient.get('/cabinet/admin/campaigns/available-tariffs')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
227
src/api/promoOffers.ts
Normal file
227
src/api/promoOffers.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import apiClient from './client'
|
||||
|
||||
// ============== Types ==============
|
||||
|
||||
export interface PromoOfferUserInfo {
|
||||
id: number
|
||||
telegram_id: number
|
||||
username: string | null
|
||||
first_name: string | null
|
||||
last_name: string | null
|
||||
full_name: string | null
|
||||
}
|
||||
|
||||
export interface PromoOfferSubscriptionInfo {
|
||||
id: number
|
||||
status: string
|
||||
is_trial: boolean
|
||||
start_date: string
|
||||
end_date: string
|
||||
autopay_enabled: boolean
|
||||
}
|
||||
|
||||
export interface PromoOffer {
|
||||
id: number
|
||||
user_id: number
|
||||
subscription_id: number | null
|
||||
notification_type: string
|
||||
discount_percent: number
|
||||
bonus_amount_kopeks: number
|
||||
expires_at: string
|
||||
claimed_at: string | null
|
||||
is_active: boolean
|
||||
effect_type: string
|
||||
extra_data: Record<string, any>
|
||||
created_at: string
|
||||
updated_at: string
|
||||
user: PromoOfferUserInfo | null
|
||||
subscription: PromoOfferSubscriptionInfo | null
|
||||
}
|
||||
|
||||
export interface PromoOfferListResponse {
|
||||
items: PromoOffer[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface PromoOfferBroadcastRequest {
|
||||
notification_type: string
|
||||
valid_hours: number
|
||||
discount_percent?: number
|
||||
bonus_amount_kopeks?: number
|
||||
effect_type?: string
|
||||
extra_data?: Record<string, any>
|
||||
target?: string
|
||||
user_id?: number
|
||||
telegram_id?: number
|
||||
}
|
||||
|
||||
export interface PromoOfferBroadcastResponse {
|
||||
created_offers: number
|
||||
user_ids: number[]
|
||||
target: string | null
|
||||
}
|
||||
|
||||
export interface PromoOfferTemplate {
|
||||
id: number
|
||||
name: string
|
||||
offer_type: string
|
||||
message_text: string
|
||||
button_text: string
|
||||
valid_hours: number
|
||||
discount_percent: number
|
||||
bonus_amount_kopeks: number
|
||||
active_discount_hours: number | null
|
||||
test_duration_hours: number | null
|
||||
test_squad_uuids: string[]
|
||||
is_active: boolean
|
||||
created_by: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PromoOfferTemplateListResponse {
|
||||
items: PromoOfferTemplate[]
|
||||
}
|
||||
|
||||
export interface PromoOfferTemplateUpdateRequest {
|
||||
name?: string
|
||||
message_text?: string
|
||||
button_text?: string
|
||||
valid_hours?: number
|
||||
discount_percent?: number
|
||||
bonus_amount_kopeks?: number
|
||||
active_discount_hours?: number
|
||||
test_duration_hours?: number
|
||||
test_squad_uuids?: string[]
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface PromoOfferLogOfferInfo {
|
||||
id: number
|
||||
notification_type: string | null
|
||||
discount_percent: number | null
|
||||
bonus_amount_kopeks: number | null
|
||||
effect_type: string | null
|
||||
expires_at: string | null
|
||||
claimed_at: string | null
|
||||
is_active: boolean | null
|
||||
}
|
||||
|
||||
export interface PromoOfferLog {
|
||||
id: number
|
||||
user_id: number | null
|
||||
offer_id: number | null
|
||||
action: string
|
||||
source: string | null
|
||||
percent: number | null
|
||||
effect_type: string | null
|
||||
details: Record<string, any>
|
||||
created_at: string
|
||||
user: PromoOfferUserInfo | null
|
||||
offer: PromoOfferLogOfferInfo | null
|
||||
}
|
||||
|
||||
export interface PromoOfferLogListResponse {
|
||||
items: PromoOfferLog[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
// Target segments for broadcast
|
||||
export const TARGET_SEGMENTS = {
|
||||
all: 'Все пользователи',
|
||||
active: 'Активные подписчики',
|
||||
trial: 'Триал пользователи',
|
||||
trial_ending: 'Заканчивается триал',
|
||||
expiring: 'Заканчивается подписка',
|
||||
expired: 'Истекшая подписка',
|
||||
zero: 'Нулевой баланс',
|
||||
autopay_failed: 'Ошибка автоплатежа',
|
||||
low_balance: 'Низкий баланс',
|
||||
inactive_30d: 'Неактивны 30 дней',
|
||||
inactive_60d: 'Неактивны 60 дней',
|
||||
inactive_90d: 'Неактивны 90 дней',
|
||||
custom_today: 'Зарегистрированы сегодня',
|
||||
custom_week: 'Зарегистрированы за неделю',
|
||||
custom_month: 'Зарегистрированы за месяц',
|
||||
custom_active_today: 'Активны сегодня',
|
||||
} as const
|
||||
|
||||
export type TargetSegment = keyof typeof TARGET_SEGMENTS
|
||||
|
||||
// Offer type configurations
|
||||
export const OFFER_TYPE_CONFIG = {
|
||||
test_access: {
|
||||
icon: '🧪',
|
||||
label: 'Тестовый доступ',
|
||||
effect: 'test_access',
|
||||
description: 'Временный доступ к дополнительным серверам',
|
||||
},
|
||||
extend_discount: {
|
||||
icon: '💎',
|
||||
label: 'Скидка на продление',
|
||||
effect: 'percent_discount',
|
||||
description: 'Скидка для текущих подписчиков',
|
||||
},
|
||||
purchase_discount: {
|
||||
icon: '🎯',
|
||||
label: 'Скидка на покупку',
|
||||
effect: 'percent_discount',
|
||||
description: 'Скидка для новых пользователей',
|
||||
},
|
||||
} as const
|
||||
|
||||
export type OfferType = keyof typeof OFFER_TYPE_CONFIG
|
||||
|
||||
// ============== API ==============
|
||||
|
||||
export const promoOffersApi = {
|
||||
// Get list of promo offers
|
||||
getOffers: async (params?: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
user_id?: number
|
||||
is_active?: boolean
|
||||
}): Promise<PromoOfferListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/promo-offers', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Broadcast offer to multiple users
|
||||
broadcastOffer: async (data: PromoOfferBroadcastRequest): Promise<PromoOfferBroadcastResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/promo-offers/broadcast', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get promo offer logs
|
||||
getLogs: async (params?: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
user_id?: number
|
||||
action?: string
|
||||
}): Promise<PromoOfferLogListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/promo-offers/logs', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get all templates
|
||||
getTemplates: async (): Promise<PromoOfferTemplateListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/promo-offers/templates')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get single template
|
||||
getTemplate: async (id: number): Promise<PromoOfferTemplate> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/promo-offers/templates/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Update template
|
||||
updateTemplate: async (id: number, data: PromoOfferTemplateUpdateRequest): Promise<PromoOfferTemplate> => {
|
||||
const response = await apiClient.patch(`/cabinet/admin/promo-offers/templates/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
181
src/api/promocodes.ts
Normal file
181
src/api/promocodes.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import apiClient from './client'
|
||||
|
||||
// ============== Types ==============
|
||||
|
||||
export type PromoCodeType = 'balance' | 'subscription_days' | 'trial_subscription' | 'promo_group'
|
||||
|
||||
export interface PromoCode {
|
||||
id: number
|
||||
code: string
|
||||
type: PromoCodeType
|
||||
balance_bonus_kopeks: number
|
||||
balance_bonus_rubles: number
|
||||
subscription_days: number
|
||||
max_uses: number
|
||||
current_uses: number
|
||||
uses_left: number
|
||||
is_active: boolean
|
||||
is_valid: boolean
|
||||
first_purchase_only: boolean
|
||||
valid_from: string
|
||||
valid_until: string | null
|
||||
promo_group_id: number | null
|
||||
created_by: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PromoCodeRecentUse {
|
||||
id: number
|
||||
user_id: number
|
||||
user_username: string | null
|
||||
user_full_name: string | null
|
||||
user_telegram_id: number | null
|
||||
used_at: string
|
||||
}
|
||||
|
||||
export interface PromoCodeDetail extends PromoCode {
|
||||
total_uses: number
|
||||
today_uses: number
|
||||
recent_uses: PromoCodeRecentUse[]
|
||||
}
|
||||
|
||||
export interface PromoCodeListResponse {
|
||||
items: PromoCode[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface PromoCodeCreateRequest {
|
||||
code: string
|
||||
type: PromoCodeType
|
||||
balance_bonus_kopeks?: number
|
||||
subscription_days?: number
|
||||
max_uses?: number
|
||||
valid_from?: string
|
||||
valid_until?: string | null
|
||||
is_active?: boolean
|
||||
first_purchase_only?: boolean
|
||||
promo_group_id?: number | null
|
||||
}
|
||||
|
||||
export interface PromoCodeUpdateRequest {
|
||||
code?: string
|
||||
type?: PromoCodeType
|
||||
balance_bonus_kopeks?: number
|
||||
subscription_days?: number
|
||||
max_uses?: number
|
||||
valid_from?: string
|
||||
valid_until?: string | null
|
||||
is_active?: boolean
|
||||
first_purchase_only?: boolean
|
||||
promo_group_id?: number | null
|
||||
}
|
||||
|
||||
// ============== PromoGroup Types ==============
|
||||
|
||||
export interface PromoGroup {
|
||||
id: number
|
||||
name: string
|
||||
server_discount_percent: number
|
||||
traffic_discount_percent: number
|
||||
device_discount_percent: number
|
||||
period_discounts: Record<number, number>
|
||||
auto_assign_total_spent_kopeks: number | null
|
||||
apply_discounts_to_addons: boolean
|
||||
is_default: boolean
|
||||
members_count: number
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface PromoGroupListResponse {
|
||||
items: PromoGroup[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export interface PromoGroupCreateRequest {
|
||||
name: string
|
||||
server_discount_percent?: number
|
||||
traffic_discount_percent?: number
|
||||
device_discount_percent?: number
|
||||
period_discounts?: Record<number, number>
|
||||
auto_assign_total_spent_kopeks?: number | null
|
||||
apply_discounts_to_addons?: boolean
|
||||
is_default?: boolean
|
||||
}
|
||||
|
||||
export interface PromoGroupUpdateRequest {
|
||||
name?: string
|
||||
server_discount_percent?: number
|
||||
traffic_discount_percent?: number
|
||||
device_discount_percent?: number
|
||||
period_discounts?: Record<number, number>
|
||||
auto_assign_total_spent_kopeks?: number | null
|
||||
apply_discounts_to_addons?: boolean
|
||||
is_default?: boolean
|
||||
}
|
||||
|
||||
// ============== API ==============
|
||||
|
||||
export const promocodesApi = {
|
||||
// Promocodes
|
||||
getPromocodes: async (params?: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
is_active?: boolean
|
||||
}): Promise<PromoCodeListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/promocodes', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
getPromocode: async (id: number): Promise<PromoCodeDetail> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/promocodes/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createPromocode: async (data: PromoCodeCreateRequest): Promise<PromoCode> => {
|
||||
const response = await apiClient.post('/cabinet/admin/promocodes', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
updatePromocode: async (id: number, data: PromoCodeUpdateRequest): Promise<PromoCode> => {
|
||||
const response = await apiClient.patch(`/cabinet/admin/promocodes/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deletePromocode: async (id: number): Promise<void> => {
|
||||
await apiClient.delete(`/cabinet/admin/promocodes/${id}`)
|
||||
},
|
||||
|
||||
// Promo Groups
|
||||
getPromoGroups: async (params?: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<PromoGroupListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/promo-groups', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
getPromoGroup: async (id: number): Promise<PromoGroup> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/promo-groups/${id}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
createPromoGroup: async (data: PromoGroupCreateRequest): Promise<PromoGroup> => {
|
||||
const response = await apiClient.post('/cabinet/admin/promo-groups', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
updatePromoGroup: async (id: number, data: PromoGroupUpdateRequest): Promise<PromoGroup> => {
|
||||
const response = await apiClient.patch(`/cabinet/admin/promo-groups/${id}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
deletePromoGroup: async (id: number): Promise<void> => {
|
||||
await apiClient.delete(`/cabinet/admin/promo-groups/${id}`)
|
||||
},
|
||||
}
|
||||
252
src/components/PromoOffersSection.tsx
Normal file
252
src/components/PromoOffersSection.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { promoApi, PromoOffer } from '../api/promo'
|
||||
|
||||
// Icons
|
||||
const GiftIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ClockIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SparklesIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ServerIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// Helper functions
|
||||
const formatTimeLeft = (expiresAt: string): string => {
|
||||
const now = new Date()
|
||||
const expires = new Date(expiresAt)
|
||||
const diffMs = expires.getTime() - now.getTime()
|
||||
|
||||
if (diffMs <= 0) return 'Истекло'
|
||||
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
|
||||
|
||||
if (hours > 24) {
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days} дн.`
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}ч ${minutes}м`
|
||||
}
|
||||
return `${minutes}м`
|
||||
}
|
||||
|
||||
const getOfferIcon = (effectType: string) => {
|
||||
if (effectType === 'test_access') return <ServerIcon />
|
||||
return <SparklesIcon />
|
||||
}
|
||||
|
||||
const getOfferTitle = (offer: PromoOffer): string => {
|
||||
if (offer.effect_type === 'test_access') {
|
||||
return 'Тестовый доступ'
|
||||
}
|
||||
if (offer.discount_percent) {
|
||||
return `Скидка ${offer.discount_percent}%`
|
||||
}
|
||||
return 'Специальное предложение'
|
||||
}
|
||||
|
||||
const getOfferDescription = (offer: PromoOffer): string => {
|
||||
if (offer.effect_type === 'test_access') {
|
||||
const squads = offer.extra_data?.test_squad_uuids?.length || 0
|
||||
return squads > 0 ? `Доступ к ${squads} серверам` : 'Доступ к дополнительным серверам'
|
||||
}
|
||||
return 'Активируйте скидку на покупку подписки'
|
||||
}
|
||||
|
||||
interface PromoOffersSectionProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function PromoOffersSection({ className = '' }: PromoOffersSectionProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [claimingId, setClaimingId] = useState<number | null>(null)
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
|
||||
// Fetch available offers
|
||||
const { data: offers = [], isLoading: offersLoading } = useQuery({
|
||||
queryKey: ['promo-offers'],
|
||||
queryFn: promoApi.getOffers,
|
||||
staleTime: 30000,
|
||||
})
|
||||
|
||||
// Fetch active discount
|
||||
const { data: activeDiscount } = useQuery({
|
||||
queryKey: ['active-discount'],
|
||||
queryFn: promoApi.getActiveDiscount,
|
||||
staleTime: 30000,
|
||||
})
|
||||
|
||||
// Claim offer mutation
|
||||
const claimMutation = useMutation({
|
||||
mutationFn: promoApi.claimOffer,
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['promo-offers'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['active-discount'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] })
|
||||
setSuccessMessage(result.message)
|
||||
setClaimingId(null)
|
||||
setTimeout(() => setSuccessMessage(null), 5000)
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setErrorMessage(error.response?.data?.detail || 'Не удалось активировать предложение')
|
||||
setClaimingId(null)
|
||||
setTimeout(() => setErrorMessage(null), 5000)
|
||||
},
|
||||
})
|
||||
|
||||
const handleClaim = (offerId: number) => {
|
||||
setClaimingId(offerId)
|
||||
setErrorMessage(null)
|
||||
setSuccessMessage(null)
|
||||
claimMutation.mutate(offerId)
|
||||
}
|
||||
|
||||
// Filter unclaimed and active offers
|
||||
const availableOffers = offers.filter(o => o.is_active && !o.is_claimed)
|
||||
|
||||
// Don't render if no offers and no active discount
|
||||
if (!offersLoading && availableOffers.length === 0 && (!activeDiscount || !activeDiscount.is_active)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* Active Discount Banner */}
|
||||
{activeDiscount && activeDiscount.is_active && activeDiscount.discount_percent > 0 && (
|
||||
<div className="card border-accent-500/30 bg-gradient-to-br from-accent-500/10 to-transparent">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-accent-500/20 flex items-center justify-center flex-shrink-0 text-accent-400">
|
||||
<CheckIcon />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-dark-100">
|
||||
Скидка {activeDiscount.discount_percent}% активна
|
||||
</h3>
|
||||
<span className="px-2 py-0.5 text-xs bg-accent-500/20 text-accent-400 rounded">
|
||||
Действует
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-dark-400">
|
||||
{activeDiscount.expires_at && (
|
||||
<div className="flex items-center gap-1">
|
||||
<ClockIcon />
|
||||
<span>Истекает: {formatTimeLeft(activeDiscount.expires_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success/Error Messages */}
|
||||
{successMessage && (
|
||||
<div className="p-4 bg-success-500/10 border border-success-500/30 text-success-400 rounded-xl flex items-center gap-3">
|
||||
<CheckIcon />
|
||||
<span>{successMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<div className="p-4 bg-error-500/10 border border-error-500/30 text-error-400 rounded-xl">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available Offers */}
|
||||
{availableOffers.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{availableOffers.map((offer) => (
|
||||
<div
|
||||
key={offer.id}
|
||||
className="card border-orange-500/30 bg-gradient-to-br from-orange-500/5 to-transparent hover:border-orange-500/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-orange-500/20 flex items-center justify-center flex-shrink-0 text-orange-400">
|
||||
{getOfferIcon(offer.effect_type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-dark-100">
|
||||
{getOfferTitle(offer)}
|
||||
</h3>
|
||||
{offer.effect_type === 'test_access' && (
|
||||
<span className="px-2 py-0.5 text-xs bg-purple-500/20 text-purple-400 rounded">
|
||||
Тест
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-dark-400 mb-3">
|
||||
{getOfferDescription(offer)}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-1 text-xs text-dark-500">
|
||||
<ClockIcon />
|
||||
<span>Осталось: {formatTimeLeft(offer.expires_at)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleClaim(offer.id)}
|
||||
disabled={claimingId === offer.id}
|
||||
className="px-4 py-2 bg-orange-500 text-white text-sm font-medium rounded-lg hover:bg-orange-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{claimingId === offer.id ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<span>Активация...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GiftIcon />
|
||||
<span>Активировать</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{offersLoading && (
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-dark-700 animate-pulse" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-5 w-32 bg-dark-700 rounded animate-pulse" />
|
||||
<div className="h-4 w-48 bg-dark-700 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
1859
src/locales/en.json
1859
src/locales/en.json
File diff suppressed because it is too large
Load Diff
1500
src/locales/fa.json
1500
src/locales/fa.json
File diff suppressed because it is too large
Load Diff
2017
src/locales/ru.json
2017
src/locales/ru.json
File diff suppressed because it is too large
Load Diff
1500
src/locales/zh.json
1500
src/locales/zh.json
File diff suppressed because it is too large
Load Diff
754
src/pages/AdminBroadcasts.tsx
Normal file
754
src/pages/AdminBroadcasts.tsx
Normal file
@@ -0,0 +1,754 @@
|
||||
import { useState, useRef, useMemo } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
adminBroadcastsApi,
|
||||
Broadcast,
|
||||
BroadcastFilter,
|
||||
TariffFilter,
|
||||
BroadcastCreateRequest,
|
||||
} from '../api/adminBroadcasts'
|
||||
|
||||
// Icons
|
||||
const BroadcastIcon = () => (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const PlusIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const XIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const RefreshIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const StopIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const PhotoIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const VideoIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const DocumentIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const UsersIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ChevronDownIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// Status badge component
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const statusConfig: Record<string, { bg: string; text: string; label: string }> = {
|
||||
queued: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'В очереди' },
|
||||
in_progress: { bg: 'bg-blue-500/20', text: 'text-blue-400', label: 'Отправляется' },
|
||||
completed: { bg: 'bg-green-500/20', text: 'text-green-400', label: 'Завершено' },
|
||||
partial: { bg: 'bg-orange-500/20', text: 'text-orange-400', label: 'Частично' },
|
||||
failed: { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Ошибка' },
|
||||
cancelled: { bg: 'bg-gray-500/20', text: 'text-gray-400', label: 'Отменено' },
|
||||
cancelling: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'Отменяется' },
|
||||
}
|
||||
const config = statusConfig[status] || statusConfig.queued
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${config.bg} ${config.text}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Filter labels
|
||||
const FILTER_GROUP_LABELS: Record<string, string> = {
|
||||
basic: 'Основные',
|
||||
subscription: 'По подписке',
|
||||
traffic: 'По трафику',
|
||||
registration: 'По регистрации',
|
||||
activity: 'По активности',
|
||||
source: 'По источнику',
|
||||
tariff: 'По тарифу',
|
||||
}
|
||||
|
||||
// Create broadcast modal
|
||||
interface CreateModalProps {
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [target, setTarget] = useState('')
|
||||
const [messageText, setMessageText] = useState('')
|
||||
const [selectedButtons, setSelectedButtons] = useState<string[]>(['home'])
|
||||
const [mediaFile, setMediaFile] = useState<File | null>(null)
|
||||
const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo')
|
||||
const [mediaPreview, setMediaPreview] = useState<string | null>(null)
|
||||
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
|
||||
// Fetch filters
|
||||
const { data: filtersData, isLoading: filtersLoading } = useQuery({
|
||||
queryKey: ['admin', 'broadcasts', 'filters'],
|
||||
queryFn: adminBroadcastsApi.getFilters,
|
||||
})
|
||||
|
||||
// Fetch buttons
|
||||
const { data: buttonsData } = useQuery({
|
||||
queryKey: ['admin', 'broadcasts', 'buttons'],
|
||||
queryFn: adminBroadcastsApi.getButtons,
|
||||
})
|
||||
|
||||
// Preview mutation
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: adminBroadcastsApi.preview,
|
||||
})
|
||||
|
||||
// Create mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: adminBroadcastsApi.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] })
|
||||
onSuccess()
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
|
||||
// Group filters
|
||||
const groupedFilters = useMemo(() => {
|
||||
if (!filtersData) return {}
|
||||
const groups: Record<string, (BroadcastFilter | TariffFilter)[]> = {}
|
||||
|
||||
// Basic filters
|
||||
filtersData.filters.forEach(f => {
|
||||
const group = f.group || 'basic'
|
||||
if (!groups[group]) groups[group] = []
|
||||
groups[group].push(f)
|
||||
})
|
||||
|
||||
// Tariff filters
|
||||
if (filtersData.tariff_filters.length > 0) {
|
||||
groups['tariff'] = filtersData.tariff_filters
|
||||
}
|
||||
|
||||
// Custom filters
|
||||
filtersData.custom_filters.forEach(f => {
|
||||
const group = f.group || 'custom'
|
||||
if (!groups[group]) groups[group] = []
|
||||
groups[group].push(f)
|
||||
})
|
||||
|
||||
return groups
|
||||
}, [filtersData])
|
||||
|
||||
// Selected filter info
|
||||
const selectedFilter = useMemo(() => {
|
||||
if (!target || !filtersData) return null
|
||||
const all = [
|
||||
...filtersData.filters,
|
||||
...filtersData.tariff_filters,
|
||||
...filtersData.custom_filters,
|
||||
]
|
||||
return all.find(f => f.key === target)
|
||||
}, [target, filtersData])
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setMediaFile(file)
|
||||
|
||||
// Determine media type
|
||||
if (file.type.startsWith('image/')) {
|
||||
setMediaType('photo')
|
||||
setMediaPreview(URL.createObjectURL(file))
|
||||
} else if (file.type.startsWith('video/')) {
|
||||
setMediaType('video')
|
||||
setMediaPreview(null)
|
||||
} else {
|
||||
setMediaType('document')
|
||||
setMediaPreview(null)
|
||||
}
|
||||
|
||||
// Upload file
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const result = await adminBroadcastsApi.uploadMedia(file, mediaType)
|
||||
setUploadedFileId(result.file_id)
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
setMediaFile(null)
|
||||
setMediaPreview(null)
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove media
|
||||
const handleRemoveMedia = () => {
|
||||
setMediaFile(null)
|
||||
setMediaPreview(null)
|
||||
setUploadedFileId(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle button
|
||||
const toggleButton = (key: string) => {
|
||||
setSelectedButtons(prev =>
|
||||
prev.includes(key) ? prev.filter(b => b !== key) : [...prev, key]
|
||||
)
|
||||
}
|
||||
|
||||
// Submit
|
||||
const handleSubmit = () => {
|
||||
if (!target || !messageText.trim()) return
|
||||
|
||||
const data: BroadcastCreateRequest = {
|
||||
target,
|
||||
message_text: messageText,
|
||||
selected_buttons: selectedButtons,
|
||||
}
|
||||
|
||||
if (uploadedFileId) {
|
||||
data.media = {
|
||||
type: mediaType,
|
||||
file_id: uploadedFileId,
|
||||
}
|
||||
}
|
||||
|
||||
createMutation.mutate(data)
|
||||
}
|
||||
|
||||
const recipientsCount = previewMutation.data?.count ?? selectedFilter?.count ?? null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 overflow-y-auto">
|
||||
<div className="bg-dark-800 rounded-xl w-full max-w-2xl my-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-accent-500/20 rounded-lg text-accent-400">
|
||||
<BroadcastIcon />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('admin.broadcasts.create')}</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-dark-700 rounded-lg transition-colors">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
{/* Filter selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
{t('admin.broadcasts.selectFilter')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="w-full p-3 bg-dark-700 rounded-lg text-left flex items-center justify-between hover:bg-dark-600 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<UsersIcon />
|
||||
<span className={selectedFilter ? 'text-dark-100' : 'text-dark-400'}>
|
||||
{selectedFilter ? selectedFilter.label : t('admin.broadcasts.selectFilterPlaceholder')}
|
||||
</span>
|
||||
{recipientsCount !== null && (
|
||||
<span className="px-2 py-0.5 bg-accent-500/20 text-accent-400 rounded-full text-xs">
|
||||
{recipientsCount} {t('admin.broadcasts.recipients')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDownIcon />
|
||||
</button>
|
||||
|
||||
{showFilters && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-dark-700 rounded-lg shadow-xl z-10 max-h-64 overflow-y-auto">
|
||||
{filtersLoading ? (
|
||||
<div className="p-4 text-center text-dark-400">Loading...</div>
|
||||
) : (
|
||||
Object.entries(groupedFilters).map(([group, filters]) => (
|
||||
<div key={group}>
|
||||
<div className="px-3 py-2 text-xs font-medium text-dark-400 bg-dark-800 sticky top-0">
|
||||
{FILTER_GROUP_LABELS[group] || group}
|
||||
</div>
|
||||
{filters.map(filter => (
|
||||
<button
|
||||
key={filter.key}
|
||||
onClick={() => {
|
||||
setTarget(filter.key)
|
||||
setShowFilters(false)
|
||||
previewMutation.mutate(filter.key)
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-dark-600 transition-colors flex items-center justify-between ${
|
||||
target === filter.key ? 'bg-accent-500/20' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-dark-100">{filter.label}</span>
|
||||
{filter.count !== null && filter.count !== undefined && (
|
||||
<span className="text-xs text-dark-400">{filter.count}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message text */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
{t('admin.broadcasts.messageText')}
|
||||
</label>
|
||||
<textarea
|
||||
value={messageText}
|
||||
onChange={e => setMessageText(e.target.value)}
|
||||
placeholder={t('admin.broadcasts.messageTextPlaceholder')}
|
||||
rows={5}
|
||||
maxLength={4000}
|
||||
className="w-full p-3 bg-dark-700 rounded-lg text-dark-100 placeholder-dark-400 resize-none focus:outline-none focus:ring-2 focus:ring-accent-500"
|
||||
/>
|
||||
<div className="text-xs text-dark-400 mt-1 text-right">
|
||||
{messageText.length}/4000
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
{t('admin.broadcasts.media')}
|
||||
</label>
|
||||
{mediaFile ? (
|
||||
<div className="p-3 bg-dark-700 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{mediaType === 'photo' && <PhotoIcon />}
|
||||
{mediaType === 'video' && <VideoIcon />}
|
||||
{mediaType === 'document' && <DocumentIcon />}
|
||||
<div>
|
||||
<p className="text-sm text-dark-100">{mediaFile.name}</p>
|
||||
<p className="text-xs text-dark-400">
|
||||
{(mediaFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRemoveMedia}
|
||||
className="p-2 hover:bg-dark-600 rounded-lg text-dark-400 hover:text-red-400"
|
||||
disabled={isUploading}
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
{mediaPreview && (
|
||||
<img
|
||||
src={mediaPreview}
|
||||
alt="Preview"
|
||||
className="mt-3 max-h-40 rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
{isUploading && (
|
||||
<div className="mt-2 text-sm text-accent-400">{t('admin.broadcasts.uploading')}</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*,application/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex-1 p-3 bg-dark-700 rounded-lg text-dark-400 hover:bg-dark-600 hover:text-dark-100 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<PhotoIcon />
|
||||
<span>{t('admin.broadcasts.addMedia')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
{t('admin.broadcasts.buttons')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{buttonsData?.buttons.map(button => (
|
||||
<button
|
||||
key={button.key}
|
||||
onClick={() => toggleButton(button.key)}
|
||||
className={`px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
selectedButtons.includes(button.key)
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
{button.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-4 border-t border-dark-700">
|
||||
<div className="text-sm text-dark-400">
|
||||
{recipientsCount !== null && (
|
||||
<span>
|
||||
{t('admin.broadcasts.willBeSent')}: <strong className="text-accent-400">{recipientsCount}</strong> {t('admin.broadcasts.users')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-dark-700 rounded-lg text-dark-300 hover:bg-dark-600 transition-colors"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!target || !messageText.trim() || createMutation.isPending || isUploading}
|
||||
className="px-4 py-2 bg-accent-500 rounded-lg text-white hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<RefreshIcon />
|
||||
) : (
|
||||
<BroadcastIcon />
|
||||
)}
|
||||
{t('admin.broadcasts.send')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Broadcast detail modal
|
||||
interface DetailModalProps {
|
||||
broadcast: Broadcast
|
||||
onClose: () => void
|
||||
onStop: () => void
|
||||
isStopping: boolean
|
||||
}
|
||||
|
||||
function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: DetailModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const isRunning = ['queued', 'in_progress', 'cancelling'].includes(broadcast.status)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl w-full max-w-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge status={broadcast.status} />
|
||||
<span className="text-dark-400">#{broadcast.id}</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-dark-700 rounded-lg transition-colors">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Progress */}
|
||||
{isRunning && (
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-dark-400">{t('admin.broadcasts.progress')}</span>
|
||||
<span className="text-dark-100">{broadcast.progress_percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-500 transition-all duration-300"
|
||||
style={{ width: `${broadcast.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="p-3 bg-dark-700 rounded-lg text-center">
|
||||
<p className="text-2xl font-bold text-dark-100">{broadcast.total_count}</p>
|
||||
<p className="text-xs text-dark-400">{t('admin.broadcasts.total')}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-700 rounded-lg text-center">
|
||||
<p className="text-2xl font-bold text-green-400">{broadcast.sent_count}</p>
|
||||
<p className="text-xs text-dark-400">{t('admin.broadcasts.sent')}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-700 rounded-lg text-center">
|
||||
<p className="text-2xl font-bold text-red-400">{broadcast.failed_count}</p>
|
||||
<p className="text-xs text-dark-400">{t('admin.broadcasts.failed')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target */}
|
||||
<div>
|
||||
<p className="text-sm text-dark-400 mb-1">{t('admin.broadcasts.filter')}</p>
|
||||
<p className="text-dark-100">{broadcast.target_type}</p>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<p className="text-sm text-dark-400 mb-1">{t('admin.broadcasts.message')}</p>
|
||||
<div className="p-3 bg-dark-700 rounded-lg text-dark-100 whitespace-pre-wrap text-sm max-h-40 overflow-y-auto">
|
||||
{broadcast.message_text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media */}
|
||||
{broadcast.has_media && (
|
||||
<div>
|
||||
<p className="text-sm text-dark-400 mb-1">{t('admin.broadcasts.media')}</p>
|
||||
<div className="flex items-center gap-2 text-dark-100">
|
||||
{broadcast.media_type === 'photo' && <PhotoIcon />}
|
||||
{broadcast.media_type === 'video' && <VideoIcon />}
|
||||
{broadcast.media_type === 'document' && <DocumentIcon />}
|
||||
<span className="capitalize">{broadcast.media_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Admin & Time */}
|
||||
<div className="flex justify-between text-sm text-dark-400">
|
||||
<span>{broadcast.admin_name || t('admin.broadcasts.unknownAdmin')}</span>
|
||||
<span>{new Date(broadcast.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{isRunning && broadcast.status !== 'cancelling' && (
|
||||
<div className="p-4 border-t border-dark-700">
|
||||
<button
|
||||
onClick={onStop}
|
||||
disabled={isStopping}
|
||||
className="w-full py-2 bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<StopIcon />
|
||||
{t('admin.broadcasts.stop')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Main component
|
||||
export default function AdminBroadcasts() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [selectedBroadcast, setSelectedBroadcast] = useState<Broadcast | null>(null)
|
||||
const [page, setPage] = useState(0)
|
||||
const limit = 20
|
||||
|
||||
// Fetch broadcasts
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['admin', 'broadcasts', 'list', page],
|
||||
queryFn: () => adminBroadcastsApi.list(limit, page * limit),
|
||||
refetchInterval: 5000, // Auto refresh every 5s
|
||||
})
|
||||
|
||||
// Stop mutation
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: adminBroadcastsApi.stop,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] })
|
||||
setSelectedBroadcast(null)
|
||||
},
|
||||
})
|
||||
|
||||
const broadcasts = data?.items || []
|
||||
const total = data?.total || 0
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-900 p-4 pb-20 md:pb-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-accent-500/20 rounded-lg text-accent-400">
|
||||
<BroadcastIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-dark-100">{t('admin.broadcasts.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.broadcasts.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="p-2 bg-dark-800 rounded-lg text-dark-400 hover:text-dark-100 transition-colors"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2 bg-accent-500 rounded-lg text-white hover:bg-accent-600 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<PlusIcon />
|
||||
<span className="hidden sm:inline">{t('admin.broadcasts.create')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Broadcasts list */}
|
||||
<div className="bg-dark-800 rounded-xl overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-dark-400">
|
||||
<RefreshIcon />
|
||||
<p className="mt-2">{t('common.loading')}</p>
|
||||
</div>
|
||||
) : broadcasts.length === 0 ? (
|
||||
<div className="p-8 text-center text-dark-400">
|
||||
<BroadcastIcon />
|
||||
<p className="mt-2">{t('admin.broadcasts.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-dark-700">
|
||||
{broadcasts.map(broadcast => (
|
||||
<button
|
||||
key={broadcast.id}
|
||||
onClick={() => setSelectedBroadcast(broadcast)}
|
||||
className="w-full p-4 hover:bg-dark-700/50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<StatusBadge status={broadcast.status} />
|
||||
<span className="text-xs text-dark-400">#{broadcast.id}</span>
|
||||
{broadcast.has_media && (
|
||||
<span className="text-dark-400">
|
||||
{broadcast.media_type === 'photo' && <PhotoIcon />}
|
||||
{broadcast.media_type === 'video' && <VideoIcon />}
|
||||
{broadcast.media_type === 'document' && <DocumentIcon />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-dark-100 text-sm truncate">
|
||||
{broadcast.message_text}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-dark-400">
|
||||
<span>{broadcast.target_type}</span>
|
||||
<span>
|
||||
{broadcast.sent_count}/{broadcast.total_count}
|
||||
</span>
|
||||
<span>{new Date(broadcast.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{['queued', 'in_progress'].includes(broadcast.status) && (
|
||||
<div className="w-16">
|
||||
<div className="h-1.5 bg-dark-600 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-500"
|
||||
style={{ width: `${broadcast.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-dark-400 text-center mt-1">
|
||||
{broadcast.progress_percent.toFixed(0)}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 p-4 border-t border-dark-700">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-3 py-1 bg-dark-700 rounded-lg text-dark-300 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t('common.prev')}
|
||||
</button>
|
||||
<span className="text-dark-400">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="px-3 py-1 bg-dark-700 rounded-lg text-dark-300 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t('common.next')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
{showCreateModal && (
|
||||
<CreateBroadcastModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSuccess={() => {
|
||||
refetch()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedBroadcast && (
|
||||
<BroadcastDetailModal
|
||||
broadcast={selectedBroadcast}
|
||||
onClose={() => setSelectedBroadcast(null)}
|
||||
onStop={() => stopMutation.mutate(selectedBroadcast.id)}
|
||||
isStopping={stopMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
928
src/pages/AdminCampaigns.tsx
Normal file
928
src/pages/AdminCampaigns.tsx
Normal file
@@ -0,0 +1,928 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
campaignsApi,
|
||||
CampaignListItem,
|
||||
CampaignDetail,
|
||||
CampaignStatistics,
|
||||
CampaignCreateRequest,
|
||||
CampaignUpdateRequest,
|
||||
CampaignBonusType,
|
||||
ServerSquadInfo,
|
||||
TariffListItem,
|
||||
CampaignRegistrationItem,
|
||||
} from '../api/campaigns'
|
||||
|
||||
// Icons
|
||||
const CampaignIcon = () => (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5M9 11.25v1.5M12 9v3.75m3-6v6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const PlusIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const EditIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const TrashIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const XIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ChartIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const UsersIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const LinkIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CopyIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// Bonus type labels and colors
|
||||
const bonusTypeConfig: Record<CampaignBonusType, { label: string; color: string; bgColor: string }> = {
|
||||
balance: { label: 'Баланс', color: 'text-emerald-400', bgColor: 'bg-emerald-500/20' },
|
||||
subscription: { label: 'Подписка', color: 'text-blue-400', bgColor: 'bg-blue-500/20' },
|
||||
tariff: { label: 'Тариф', color: 'text-purple-400', bgColor: 'bg-purple-500/20' },
|
||||
none: { label: 'Только ссылка', color: 'text-gray-400', bgColor: 'bg-gray-500/20' },
|
||||
}
|
||||
|
||||
// Format number as rubles
|
||||
const formatRubles = (kopeks: number) => (kopeks / 100).toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 2 }) + ' ₽'
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
// Campaign Modal
|
||||
interface CampaignModalProps {
|
||||
campaign?: CampaignDetail | null
|
||||
servers: ServerSquadInfo[]
|
||||
tariffs: TariffListItem[]
|
||||
onSave: (data: CampaignCreateRequest | CampaignUpdateRequest) => void
|
||||
onClose: () => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
function CampaignModal({ campaign, servers, tariffs, onSave, onClose, isLoading }: CampaignModalProps) {
|
||||
const isEdit = !!campaign
|
||||
|
||||
const [name, setName] = useState(campaign?.name || '')
|
||||
const [startParameter, setStartParameter] = useState(campaign?.start_parameter || '')
|
||||
const [bonusType, setBonusType] = useState<CampaignBonusType>(campaign?.bonus_type || 'balance')
|
||||
const [isActive, setIsActive] = useState(campaign?.is_active ?? true)
|
||||
|
||||
// Balance bonus
|
||||
const [balanceBonusRubles, setBalanceBonusRubles] = useState((campaign?.balance_bonus_kopeks || 0) / 100)
|
||||
|
||||
// Subscription bonus
|
||||
const [subscriptionDays, setSubscriptionDays] = useState(campaign?.subscription_duration_days || 7)
|
||||
const [subscriptionTraffic, setSubscriptionTraffic] = useState(campaign?.subscription_traffic_gb || 10)
|
||||
const [subscriptionDevices, setSubscriptionDevices] = useState(campaign?.subscription_device_limit || 1)
|
||||
const [selectedSquads, setSelectedSquads] = useState<string[]>(campaign?.subscription_squads || [])
|
||||
|
||||
// Tariff bonus
|
||||
const [tariffId, setTariffId] = useState<number | null>(campaign?.tariff_id || null)
|
||||
const [tariffDays, setTariffDays] = useState(campaign?.tariff_duration_days || 30)
|
||||
|
||||
const handleSubmit = () => {
|
||||
const data: CampaignCreateRequest | CampaignUpdateRequest = {
|
||||
name,
|
||||
start_parameter: startParameter,
|
||||
bonus_type: bonusType,
|
||||
is_active: isActive,
|
||||
}
|
||||
|
||||
if (bonusType === 'balance') {
|
||||
data.balance_bonus_kopeks = Math.round(balanceBonusRubles * 100)
|
||||
} else if (bonusType === 'subscription') {
|
||||
data.subscription_duration_days = subscriptionDays
|
||||
data.subscription_traffic_gb = subscriptionTraffic
|
||||
data.subscription_device_limit = subscriptionDevices
|
||||
data.subscription_squads = selectedSquads
|
||||
} else if (bonusType === 'tariff') {
|
||||
data.tariff_id = tariffId || undefined
|
||||
data.tariff_duration_days = tariffDays
|
||||
}
|
||||
|
||||
onSave(data)
|
||||
}
|
||||
|
||||
const toggleServer = (uuid: string) => {
|
||||
setSelectedSquads(prev =>
|
||||
prev.includes(uuid)
|
||||
? prev.filter(s => s !== uuid)
|
||||
: [...prev, uuid]
|
||||
)
|
||||
}
|
||||
|
||||
const isValid = name.trim() && startParameter.trim() && /^[a-zA-Z0-9_-]+$/.test(startParameter)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{isEdit ? 'Редактирование кампании' : 'Новая кампания'}
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
placeholder="Например: Instagram Реклама"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Start Parameter */}
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">Метка (start параметр)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={startParameter}
|
||||
onChange={e => setStartParameter(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ''))}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
placeholder="instagram_jan2024"
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">Только латиница, цифры, _ и -</p>
|
||||
</div>
|
||||
|
||||
{/* Bonus Type */}
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-2">Тип бонуса</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(Object.keys(bonusTypeConfig) as CampaignBonusType[]).map(type => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setBonusType(type)}
|
||||
className={`p-3 rounded-lg text-left transition-colors ${
|
||||
bonusType === type
|
||||
? `${bonusTypeConfig[type].bgColor} border border-current ${bonusTypeConfig[type].color}`
|
||||
: 'bg-dark-700 border border-dark-600 text-dark-300 hover:border-dark-500'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium">{bonusTypeConfig[type].label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bonus Settings */}
|
||||
{bonusType === 'balance' && (
|
||||
<div className="p-4 bg-emerald-500/10 border border-emerald-500/30 rounded-lg">
|
||||
<label className="block text-sm text-emerald-400 font-medium mb-2">Бонус на баланс</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={balanceBonusRubles}
|
||||
onChange={e => setBalanceBonusRubles(Math.max(0, parseFloat(e.target.value) || 0))}
|
||||
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-emerald-500"
|
||||
min={0}
|
||||
step={1}
|
||||
/>
|
||||
<span className="text-dark-400">₽</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bonusType === 'subscription' && (
|
||||
<div className="p-4 bg-blue-500/10 border border-blue-500/30 rounded-lg space-y-3">
|
||||
<label className="block text-sm text-blue-400 font-medium">Пробная подписка</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs text-dark-500 mb-1">Дней</label>
|
||||
<input
|
||||
type="number"
|
||||
value={subscriptionDays}
|
||||
onChange={e => setSubscriptionDays(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-blue-500"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-dark-500 mb-1">Трафик (ГБ)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={subscriptionTraffic}
|
||||
onChange={e => setSubscriptionTraffic(Math.max(0, parseInt(e.target.value) || 0))}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-blue-500"
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-dark-500 mb-1">Устройств</label>
|
||||
<input
|
||||
type="number"
|
||||
value={subscriptionDevices}
|
||||
onChange={e => setSubscriptionDevices(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-blue-500"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{servers.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs text-dark-500 mb-2">Серверы</label>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{servers.map(server => (
|
||||
<button
|
||||
key={server.id}
|
||||
type="button"
|
||||
onClick={() => toggleServer(server.squad_uuid)}
|
||||
className={`w-full flex items-center gap-2 p-2 rounded-lg text-left transition-colors ${
|
||||
selectedSquads.includes(server.squad_uuid)
|
||||
? 'bg-blue-500/20 text-blue-300'
|
||||
: 'bg-dark-600 text-dark-300 hover:bg-dark-500'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-4 h-4 rounded flex items-center justify-center ${
|
||||
selectedSquads.includes(server.squad_uuid) ? 'bg-blue-500 text-white' : 'bg-dark-500'
|
||||
}`}>
|
||||
{selectedSquads.includes(server.squad_uuid) && <CheckIcon />}
|
||||
</div>
|
||||
<span className="text-sm">{server.display_name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bonusType === 'tariff' && (
|
||||
<div className="p-4 bg-purple-500/10 border border-purple-500/30 rounded-lg space-y-3">
|
||||
<label className="block text-sm text-purple-400 font-medium">Тариф</label>
|
||||
<div>
|
||||
<label className="block text-xs text-dark-500 mb-1">Выберите тариф</label>
|
||||
<select
|
||||
value={tariffId || ''}
|
||||
onChange={e => setTariffId(e.target.value ? parseInt(e.target.value) : null)}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-purple-500"
|
||||
>
|
||||
<option value="">Не выбран</option>
|
||||
{tariffs.map(tariff => (
|
||||
<option key={tariff.id} value={tariff.id}>
|
||||
{tariff.name} ({tariff.traffic_limit_gb} ГБ, {tariff.device_limit} уст.)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-dark-500 mb-1">Длительность (дней)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={tariffDays}
|
||||
onChange={e => setTariffDays(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-purple-500"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bonusType === 'none' && (
|
||||
<div className="p-4 bg-gray-500/10 border border-gray-500/30 rounded-lg">
|
||||
<p className="text-sm text-dark-400">
|
||||
Кампания без бонусов - только для отслеживания переходов и регистраций.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active toggle */}
|
||||
<div className="flex items-center justify-between p-3 bg-dark-700 rounded-lg">
|
||||
<span className="text-sm text-dark-300">Активна</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsActive(!isActive)}
|
||||
className={`w-10 h-6 rounded-full transition-colors relative ${
|
||||
isActive ? 'bg-accent-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
isActive ? 'left-5' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid || isLoading}
|
||||
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Statistics Modal
|
||||
interface StatsModalProps {
|
||||
stats: CampaignStatistics
|
||||
onClose: () => void
|
||||
onViewUsers: () => void
|
||||
}
|
||||
|
||||
function StatsModal({ stats, onClose, onViewUsers }: StatsModalProps) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const copyLink = () => {
|
||||
if (stats.deep_link) {
|
||||
navigator.clipboard.writeText(stats.deep_link)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">{stats.name}</h2>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${bonusTypeConfig[stats.bonus_type].bgColor} ${bonusTypeConfig[stats.bonus_type].color}`}>
|
||||
{bonusTypeConfig[stats.bonus_type].label}
|
||||
</span>
|
||||
{stats.is_active ? (
|
||||
<span className="px-2 py-0.5 text-xs bg-success-500/20 text-success-400 rounded">Активна</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 text-xs bg-dark-600 text-dark-400 rounded">Неактивна</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Deep Link */}
|
||||
{stats.deep_link && (
|
||||
<div className="p-3 bg-dark-700 rounded-lg">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<LinkIcon />
|
||||
<span className="text-sm text-dark-300 truncate">{stats.deep_link}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-dark-600 text-dark-300 rounded-lg hover:bg-dark-500 transition-colors shrink-0"
|
||||
>
|
||||
<CopyIcon />
|
||||
<span className="text-sm">{copied ? 'Скопировано!' : 'Копировать'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="p-3 bg-dark-700 rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-dark-100">{stats.registrations}</div>
|
||||
<div className="text-xs text-dark-500">Регистраций</div>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-700 rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-emerald-400">{formatRubles(stats.total_revenue_kopeks)}</div>
|
||||
<div className="text-xs text-dark-500">Доход</div>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-700 rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-blue-400">{stats.paid_users_count}</div>
|
||||
<div className="text-xs text-dark-500">Оплатили</div>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-700 rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-purple-400">{stats.conversion_rate}%</div>
|
||||
<div className="text-xs text-dark-500">Конверсия</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Stats */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-dark-700/50 rounded-lg">
|
||||
<div className="text-sm text-dark-400 mb-2">Бонусы выданы</div>
|
||||
{stats.bonus_type === 'balance' && (
|
||||
<div className="text-lg font-medium text-emerald-400">{formatRubles(stats.balance_issued_kopeks)}</div>
|
||||
)}
|
||||
{stats.bonus_type === 'subscription' && (
|
||||
<div className="text-lg font-medium text-blue-400">{stats.subscription_issued} подписок</div>
|
||||
)}
|
||||
{stats.bonus_type === 'tariff' && (
|
||||
<div className="text-lg font-medium text-purple-400">{stats.subscription_issued} тарифов</div>
|
||||
)}
|
||||
{stats.bonus_type === 'none' && (
|
||||
<div className="text-lg font-medium text-dark-400">-</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 bg-dark-700/50 rounded-lg">
|
||||
<div className="text-sm text-dark-400 mb-2">Средний доход с пользователя</div>
|
||||
<div className="text-lg font-medium text-dark-200">{formatRubles(stats.avg_revenue_per_user_kopeks)}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-700/50 rounded-lg">
|
||||
<div className="text-sm text-dark-400 mb-2">Средний первый платёж</div>
|
||||
<div className="text-lg font-medium text-dark-200">{formatRubles(stats.avg_first_payment_kopeks)}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-700/50 rounded-lg">
|
||||
<div className="text-sm text-dark-400 mb-2">Пробных подписок</div>
|
||||
<div className="text-lg font-medium text-dark-200">{stats.trial_users_count} (активных: {stats.active_trials_count})</div>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-700/50 rounded-lg">
|
||||
<div className="text-sm text-dark-400 mb-2">Конверсия из триала</div>
|
||||
<div className="text-lg font-medium text-dark-200">{stats.trial_conversion_rate}%</div>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-700/50 rounded-lg">
|
||||
<div className="text-sm text-dark-400 mb-2">Последняя регистрация</div>
|
||||
<div className="text-sm font-medium text-dark-200">{formatDate(stats.last_registration)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-between gap-3 p-4 border-t border-dark-700">
|
||||
<button
|
||||
onClick={onViewUsers}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 transition-colors"
|
||||
>
|
||||
<UsersIcon />
|
||||
Пользователи
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Users Modal
|
||||
interface UsersModalProps {
|
||||
campaignId: number
|
||||
campaignName: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function UsersModal({ campaignId, campaignName, onClose }: UsersModalProps) {
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['campaign-registrations', campaignId, page],
|
||||
queryFn: () => campaignsApi.getCampaignRegistrations(campaignId, page, 20),
|
||||
})
|
||||
|
||||
const registrations = data?.registrations || []
|
||||
const total = data?.total || 0
|
||||
const totalPages = Math.ceil(total / 20)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">Пользователи кампании</h2>
|
||||
<p className="text-sm text-dark-400">{campaignName} - {total} регистраций</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : registrations.length === 0 ? (
|
||||
<div className="text-center py-12 text-dark-400">
|
||||
Нет зарегистрированных пользователей
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="bg-dark-700 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left text-xs font-medium text-dark-400 p-3">Пользователь</th>
|
||||
<th className="text-left text-xs font-medium text-dark-400 p-3">Бонус</th>
|
||||
<th className="text-left text-xs font-medium text-dark-400 p-3">Баланс</th>
|
||||
<th className="text-left text-xs font-medium text-dark-400 p-3">Статус</th>
|
||||
<th className="text-left text-xs font-medium text-dark-400 p-3">Дата</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-700">
|
||||
{registrations.map((reg: CampaignRegistrationItem) => (
|
||||
<tr key={reg.id} className="hover:bg-dark-700/50">
|
||||
<td className="p-3">
|
||||
<div className="text-sm text-dark-100">
|
||||
{reg.first_name || 'Без имени'}
|
||||
{reg.username && <span className="text-dark-400 ml-1">@{reg.username}</span>}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{reg.telegram_id}</div>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${bonusTypeConfig[reg.bonus_type as CampaignBonusType]?.bgColor || 'bg-dark-600'} ${bonusTypeConfig[reg.bonus_type as CampaignBonusType]?.color || 'text-dark-400'}`}>
|
||||
{bonusTypeConfig[reg.bonus_type as CampaignBonusType]?.label || reg.bonus_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-dark-300">{formatRubles(reg.user_balance_kopeks)}</td>
|
||||
<td className="p-3">
|
||||
<div className="flex gap-1">
|
||||
{reg.has_subscription && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-blue-500/20 text-blue-400 rounded">VPN</span>
|
||||
)}
|
||||
{reg.has_paid && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-emerald-500/20 text-emerald-400 rounded">Платил</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-dark-400">{formatDate(reg.created_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 p-4 border-t border-dark-700">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1.5 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<span className="text-sm text-dark-400">
|
||||
{page} из {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1.5 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Далее
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Main Component
|
||||
export default function AdminCampaigns() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingCampaign, setEditingCampaign] = useState<CampaignDetail | null>(null)
|
||||
const [showStats, setShowStats] = useState<CampaignStatistics | null>(null)
|
||||
const [showUsers, setShowUsers] = useState<{ id: number; name: string } | null>(null)
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
|
||||
|
||||
// Queries
|
||||
const { data: campaignsData, isLoading } = useQuery({
|
||||
queryKey: ['admin-campaigns'],
|
||||
queryFn: () => campaignsApi.getCampaigns(true),
|
||||
})
|
||||
|
||||
const { data: overview } = useQuery({
|
||||
queryKey: ['admin-campaigns-overview'],
|
||||
queryFn: () => campaignsApi.getOverview(),
|
||||
})
|
||||
|
||||
const { data: servers = [] } = useQuery({
|
||||
queryKey: ['admin-campaigns-servers'],
|
||||
queryFn: () => campaignsApi.getAvailableServers(),
|
||||
})
|
||||
|
||||
const { data: tariffs = [] } = useQuery({
|
||||
queryKey: ['admin-campaigns-tariffs'],
|
||||
queryFn: () => campaignsApi.getAvailableTariffs(),
|
||||
})
|
||||
|
||||
// Mutations
|
||||
const createMutation = useMutation({
|
||||
mutationFn: campaignsApi.createCampaign,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-campaigns'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-campaigns-overview'] })
|
||||
setShowModal(false)
|
||||
},
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: CampaignUpdateRequest }) =>
|
||||
campaignsApi.updateCampaign(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-campaigns'] })
|
||||
setShowModal(false)
|
||||
setEditingCampaign(null)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: campaignsApi.deleteCampaign,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-campaigns'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-campaigns-overview'] })
|
||||
setDeleteConfirm(null)
|
||||
},
|
||||
})
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: campaignsApi.toggleCampaign,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-campaigns'] })
|
||||
},
|
||||
})
|
||||
|
||||
const handleEdit = async (campaignId: number) => {
|
||||
try {
|
||||
const detail = await campaignsApi.getCampaign(campaignId)
|
||||
setEditingCampaign(detail)
|
||||
setShowModal(true)
|
||||
} catch (error) {
|
||||
console.error('Failed to load campaign:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewStats = async (campaignId: number) => {
|
||||
try {
|
||||
const stats = await campaignsApi.getCampaignStats(campaignId)
|
||||
setShowStats(stats)
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = (data: CampaignCreateRequest | CampaignUpdateRequest) => {
|
||||
if (editingCampaign) {
|
||||
updateMutation.mutate({ id: editingCampaign.id, data })
|
||||
} else {
|
||||
createMutation.mutate(data as CampaignCreateRequest)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setShowModal(false)
|
||||
setEditingCampaign(null)
|
||||
}
|
||||
|
||||
const campaigns = campaignsData?.campaigns || []
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-orange-500/20 rounded-lg text-orange-400">
|
||||
<CampaignIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">Рекламные кампании</h1>
|
||||
<p className="text-sm text-dark-400">Управление рекламными ссылками</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setEditingCampaign(null); setShowModal(true) }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors"
|
||||
>
|
||||
<PlusIcon />
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Overview */}
|
||||
{overview && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
|
||||
<div className="p-4 bg-dark-800 rounded-xl border border-dark-700">
|
||||
<div className="text-2xl font-bold text-dark-100">{overview.total}</div>
|
||||
<div className="text-sm text-dark-400">Всего кампаний</div>
|
||||
</div>
|
||||
<div className="p-4 bg-dark-800 rounded-xl border border-dark-700">
|
||||
<div className="text-2xl font-bold text-success-400">{overview.active}</div>
|
||||
<div className="text-sm text-dark-400">Активных</div>
|
||||
</div>
|
||||
<div className="p-4 bg-dark-800 rounded-xl border border-dark-700">
|
||||
<div className="text-2xl font-bold text-blue-400">{overview.total_registrations}</div>
|
||||
<div className="text-sm text-dark-400">Регистраций</div>
|
||||
</div>
|
||||
<div className="p-4 bg-dark-800 rounded-xl border border-dark-700">
|
||||
<div className="text-2xl font-bold text-emerald-400">{formatRubles(overview.total_balance_issued_kopeks)}</div>
|
||||
<div className="text-sm text-dark-400">Выдано бонусов</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Campaigns List */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : campaigns.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-dark-400">Нет рекламных кампаний</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{campaigns.map((campaign: CampaignListItem) => (
|
||||
<div
|
||||
key={campaign.id}
|
||||
className={`p-4 bg-dark-800 rounded-xl border transition-colors ${
|
||||
campaign.is_active ? 'border-dark-700' : 'border-dark-700/50 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-medium text-dark-100 truncate">{campaign.name}</h3>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${bonusTypeConfig[campaign.bonus_type].bgColor} ${bonusTypeConfig[campaign.bonus_type].color}`}>
|
||||
{bonusTypeConfig[campaign.bonus_type].label}
|
||||
</span>
|
||||
{!campaign.is_active && (
|
||||
<span className="px-2 py-0.5 text-xs bg-dark-600 text-dark-400 rounded">
|
||||
Неактивна
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
|
||||
<span className="font-mono text-xs">?start={campaign.start_parameter}</span>
|
||||
<span>{campaign.registrations_count} регистраций</span>
|
||||
<span>{formatRubles(campaign.total_revenue_kopeks)} доход</span>
|
||||
<span>{campaign.conversion_rate}% конверсия</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Stats */}
|
||||
<button
|
||||
onClick={() => handleViewStats(campaign.id)}
|
||||
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 hover:text-dark-100 transition-colors"
|
||||
title="Статистика"
|
||||
>
|
||||
<ChartIcon />
|
||||
</button>
|
||||
|
||||
{/* Toggle Active */}
|
||||
<button
|
||||
onClick={() => toggleMutation.mutate(campaign.id)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
campaign.is_active
|
||||
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
|
||||
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
|
||||
}`}
|
||||
title={campaign.is_active ? 'Деактивировать' : 'Активировать'}
|
||||
>
|
||||
{campaign.is_active ? <CheckIcon /> : <XIcon />}
|
||||
</button>
|
||||
|
||||
{/* Edit */}
|
||||
<button
|
||||
onClick={() => handleEdit(campaign.id)}
|
||||
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 hover:text-dark-100 transition-colors"
|
||||
title="Редактировать"
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
|
||||
{/* Delete */}
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(campaign.id)}
|
||||
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-error-500/20 hover:text-error-400 transition-colors"
|
||||
title="Удалить"
|
||||
disabled={campaign.registrations_count > 0}
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{showModal && (
|
||||
<CampaignModal
|
||||
campaign={editingCampaign}
|
||||
servers={servers}
|
||||
tariffs={tariffs}
|
||||
onSave={handleSave}
|
||||
onClose={handleCloseModal}
|
||||
isLoading={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stats Modal */}
|
||||
{showStats && (
|
||||
<StatsModal
|
||||
stats={showStats}
|
||||
onClose={() => setShowStats(null)}
|
||||
onViewUsers={() => {
|
||||
setShowUsers({ id: showStats.id, name: showStats.name })
|
||||
setShowStats(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Users Modal */}
|
||||
{showUsers && (
|
||||
<UsersModal
|
||||
campaignId={showUsers.id}
|
||||
campaignName={showUsers.name}
|
||||
onClose={() => setShowUsers(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
{deleteConfirm !== null && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl p-6 max-w-sm w-full">
|
||||
<h3 className="text-lg font-semibold text-dark-100 mb-2">Удалить кампанию?</h3>
|
||||
<p className="text-dark-400 mb-6">Это действие нельзя отменить. Кампании с регистрациями удалить нельзя.</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(deleteConfirm)}
|
||||
className="px-4 py-2 bg-error-500 text-white rounded-lg hover:bg-error-600 transition-colors"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,77 +1,149 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// Icons
|
||||
const TicketIcon = () => (
|
||||
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
// Icons - smaller versions for mobile
|
||||
const TicketIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CogIcon = () => (
|
||||
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
const CogIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const PhoneIcon = () => (
|
||||
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
const PhoneIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const WheelIcon = () => (
|
||||
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
const WheelIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const TariffIcon = () => (
|
||||
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
const TariffIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ServerIcon = () => (
|
||||
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
const ServerIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const AdminIcon = () => (
|
||||
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
const AdminIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ChartIcon = () => (
|
||||
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
const ChartIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
interface AdminCardProps {
|
||||
const BroadcastIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const PromocodeIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CampaignIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5M9 11.25v1.5M12 9v3.75m3-6v6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const UsersIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const PaymentsIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const PromoOffersIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const RemnawaveIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ChevronRightIcon = () => (
|
||||
<svg className="w-4 h-4 text-dark-500 group-hover:text-dark-300 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
interface AdminSection {
|
||||
to: string
|
||||
icon: React.ReactNode
|
||||
mobileIcon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
color: string
|
||||
bgColor: string
|
||||
textColor: string
|
||||
}
|
||||
|
||||
function AdminCard({ to, icon, title, description, color }: AdminCardProps) {
|
||||
// Mobile compact card
|
||||
function MobileAdminCard({ to, mobileIcon, title, bgColor, textColor }: AdminSection) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={`block p-6 bg-dark-800 rounded-xl border border-dark-700 hover:border-${color}-500/50 hover:bg-dark-750 transition-all duration-200 group`}
|
||||
className="flex flex-col items-center justify-center p-3 bg-dark-800/80 rounded-2xl border border-dark-700/50 hover:border-dark-600 active:scale-95 transition-all duration-150"
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-xl bg-${color}-500/20 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`}>
|
||||
<div className={`text-${color}-400`}>
|
||||
<div className={`w-12 h-12 rounded-xl ${bgColor} flex items-center justify-center mb-2`}>
|
||||
<div className={textColor}>
|
||||
{mobileIcon}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-dark-200 text-center leading-tight">{title}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// Desktop card
|
||||
function DesktopAdminCard({ to, icon, title, description, bgColor, textColor }: AdminSection) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="group flex items-center gap-4 p-4 bg-dark-800/80 rounded-2xl border border-dark-700/50 hover:border-dark-600 hover:bg-dark-800 transition-all duration-200"
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-xl ${bgColor} flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform`}>
|
||||
<div className={textColor}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-dark-100 mb-1">{title}</h3>
|
||||
<p className="text-sm text-dark-400">{description}</p>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-dark-100 mb-0.5">{title}</h3>
|
||||
<p className="text-sm text-dark-400 truncate">{description}</p>
|
||||
</div>
|
||||
<ChevronRightIcon />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -79,75 +151,173 @@ function AdminCard({ to, icon, title, description, color }: AdminCardProps) {
|
||||
export default function AdminPanel() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const adminSections = [
|
||||
const adminSections: AdminSection[] = [
|
||||
{
|
||||
to: '/admin/dashboard',
|
||||
icon: <ChartIcon />,
|
||||
mobileIcon: <ChartIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.dashboard'),
|
||||
description: t('admin.panel.dashboardDesc'),
|
||||
color: 'success'
|
||||
color: 'success',
|
||||
bgColor: 'bg-emerald-500/20',
|
||||
textColor: 'text-emerald-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/tickets',
|
||||
icon: <TicketIcon />,
|
||||
mobileIcon: <TicketIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.tickets'),
|
||||
description: t('admin.panel.ticketsDesc'),
|
||||
color: 'warning'
|
||||
color: 'warning',
|
||||
bgColor: 'bg-amber-500/20',
|
||||
textColor: 'text-amber-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/settings',
|
||||
icon: <CogIcon />,
|
||||
mobileIcon: <CogIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.settings'),
|
||||
description: t('admin.panel.settingsDesc'),
|
||||
color: 'accent'
|
||||
color: 'accent',
|
||||
bgColor: 'bg-blue-500/20',
|
||||
textColor: 'text-blue-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/apps',
|
||||
icon: <PhoneIcon />,
|
||||
mobileIcon: <PhoneIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.apps'),
|
||||
description: t('admin.panel.appsDesc'),
|
||||
color: 'success'
|
||||
color: 'success',
|
||||
bgColor: 'bg-teal-500/20',
|
||||
textColor: 'text-teal-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/wheel',
|
||||
icon: <WheelIcon />,
|
||||
mobileIcon: <WheelIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.wheel'),
|
||||
description: t('admin.panel.wheelDesc'),
|
||||
color: 'error'
|
||||
color: 'error',
|
||||
bgColor: 'bg-rose-500/20',
|
||||
textColor: 'text-rose-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/tariffs',
|
||||
icon: <TariffIcon />,
|
||||
mobileIcon: <TariffIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.tariffs'),
|
||||
description: t('admin.panel.tariffsDesc'),
|
||||
color: 'info'
|
||||
color: 'info',
|
||||
bgColor: 'bg-cyan-500/20',
|
||||
textColor: 'text-cyan-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/servers',
|
||||
icon: <ServerIcon />,
|
||||
mobileIcon: <ServerIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.servers'),
|
||||
description: t('admin.panel.serversDesc'),
|
||||
color: 'purple'
|
||||
color: 'purple',
|
||||
bgColor: 'bg-purple-500/20',
|
||||
textColor: 'text-purple-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/broadcasts',
|
||||
icon: <BroadcastIcon />,
|
||||
mobileIcon: <BroadcastIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.broadcasts'),
|
||||
description: t('admin.panel.broadcastsDesc'),
|
||||
color: 'orange',
|
||||
bgColor: 'bg-orange-500/20',
|
||||
textColor: 'text-orange-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/promocodes',
|
||||
icon: <PromocodeIcon />,
|
||||
mobileIcon: <PromocodeIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.promocodes', 'Промокоды'),
|
||||
description: t('admin.panel.promocodesDesc', 'Управление промокодами'),
|
||||
color: 'violet',
|
||||
bgColor: 'bg-violet-500/20',
|
||||
textColor: 'text-violet-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/promo-offers',
|
||||
icon: <PromoOffersIcon />,
|
||||
mobileIcon: <PromoOffersIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.promoOffers', 'Промопредложения'),
|
||||
description: t('admin.panel.promoOffersDesc', 'Персональные скидки и предложения'),
|
||||
color: 'orange',
|
||||
bgColor: 'bg-orange-500/20',
|
||||
textColor: 'text-orange-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/campaigns',
|
||||
icon: <CampaignIcon />,
|
||||
mobileIcon: <CampaignIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.campaigns', 'Кампании'),
|
||||
description: t('admin.panel.campaignsDesc', 'Рекламные кампании'),
|
||||
color: 'orange',
|
||||
bgColor: 'bg-orange-500/20',
|
||||
textColor: 'text-orange-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/users',
|
||||
icon: <UsersIcon />,
|
||||
mobileIcon: <UsersIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.users', 'Пользователи'),
|
||||
description: t('admin.panel.usersDesc', 'Управление пользователями'),
|
||||
color: 'indigo',
|
||||
bgColor: 'bg-indigo-500/20',
|
||||
textColor: 'text-indigo-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/payments',
|
||||
icon: <PaymentsIcon />,
|
||||
mobileIcon: <PaymentsIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.payments', 'Платежи'),
|
||||
description: t('admin.panel.paymentsDesc', 'Проверка платежей'),
|
||||
color: 'lime',
|
||||
bgColor: 'bg-lime-500/20',
|
||||
textColor: 'text-lime-400'
|
||||
},
|
||||
{
|
||||
to: '/admin/remnawave',
|
||||
icon: <RemnawaveIcon />,
|
||||
mobileIcon: <RemnawaveIcon className="w-6 h-6" />,
|
||||
title: t('admin.nav.remnawave', 'RemnaWave'),
|
||||
description: t('admin.panel.remnawaveDesc', 'Управление панелью и статистика'),
|
||||
color: 'purple',
|
||||
bgColor: 'bg-purple-500/20',
|
||||
textColor: 'text-purple-400'
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<div className="p-3 bg-warning-500/20 rounded-xl">
|
||||
<AdminIcon />
|
||||
{/* Header - compact on mobile */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-2.5 sm:p-3 bg-amber-500/20 rounded-xl">
|
||||
<AdminIcon className="w-6 h-6 sm:w-8 sm:h-8 text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-dark-100">{t('admin.panel.title')}</h1>
|
||||
<p className="text-dark-400">{t('admin.panel.subtitle')}</p>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-dark-100">{t('admin.panel.title')}</h1>
|
||||
<p className="text-sm text-dark-400 hidden sm:block">{t('admin.panel.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid of admin sections */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Mobile: Compact 2-column grid */}
|
||||
<div className="grid grid-cols-3 gap-3 sm:hidden">
|
||||
{adminSections.map((section) => (
|
||||
<AdminCard key={section.to} {...section} />
|
||||
<MobileAdminCard key={section.to} {...section} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tablet/Desktop: List style */}
|
||||
<div className="hidden sm:grid sm:grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{adminSections.map((section) => (
|
||||
<DesktopAdminCard key={section.to} {...section} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
283
src/pages/AdminPayments.tsx
Normal file
283
src/pages/AdminPayments.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { adminPaymentsApi } from '../api/adminPayments'
|
||||
import { useCurrency } from '../hooks/useCurrency'
|
||||
import type { PendingPayment, PaginatedResponse } from '../types'
|
||||
|
||||
export default function AdminPayments() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { formatAmount, currencySymbol } = useCurrency()
|
||||
|
||||
const [page, setPage] = useState(1)
|
||||
const [methodFilter, setMethodFilter] = useState<string>('')
|
||||
const [checkingPaymentId, setCheckingPaymentId] = useState<string | null>(null)
|
||||
|
||||
// Fetch payments
|
||||
const { data: payments, isLoading, refetch } = useQuery<PaginatedResponse<PendingPayment>>({
|
||||
queryKey: ['admin-payments', page, methodFilter],
|
||||
queryFn: () => adminPaymentsApi.getPendingPayments({
|
||||
page,
|
||||
per_page: 20,
|
||||
method_filter: methodFilter || undefined,
|
||||
}),
|
||||
refetchInterval: 30000, // Auto-refresh every 30 seconds
|
||||
})
|
||||
|
||||
// Fetch stats
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ['admin-payments-stats'],
|
||||
queryFn: adminPaymentsApi.getStats,
|
||||
refetchInterval: 30000,
|
||||
})
|
||||
|
||||
// Check payment mutation
|
||||
const checkPaymentMutation = useMutation({
|
||||
mutationFn: ({ method, paymentId }: { method: string; paymentId: number }) =>
|
||||
adminPaymentsApi.checkPaymentStatus(method, paymentId),
|
||||
onSuccess: async (result) => {
|
||||
if (result.status_changed) {
|
||||
await refetch()
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-payments-stats'] })
|
||||
} else {
|
||||
await refetch()
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
setCheckingPaymentId(null)
|
||||
},
|
||||
})
|
||||
|
||||
const handleCheckPayment = (payment: PendingPayment) => {
|
||||
setCheckingPaymentId(`${payment.method}_${payment.id}`)
|
||||
checkPaymentMutation.mutate({ method: payment.method, paymentId: payment.id })
|
||||
}
|
||||
|
||||
// Get unique methods from stats for filter
|
||||
const methodOptions = stats?.by_method ? Object.keys(stats.by_method) : []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-dark-50">{t('admin.payments.title', 'Проверка платежей')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.payments.description', 'Ожидающие платежи за последние 24 часа')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
{t('common.refresh', 'Обновить')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
<div className="p-4 rounded-xl bg-dark-800/50 border border-dark-700/50">
|
||||
<div className="text-2xl font-bold text-dark-50">{stats.total_pending}</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.payments.totalPending', 'Всего ожидает')}</div>
|
||||
</div>
|
||||
{Object.entries(stats.by_method).map(([method, count]) => (
|
||||
<div
|
||||
key={method}
|
||||
className={`p-4 rounded-xl border cursor-pointer transition-all ${
|
||||
methodFilter === method
|
||||
? 'bg-accent-500/20 border-accent-500/50'
|
||||
: 'bg-dark-800/50 border-dark-700/50 hover:border-dark-600'
|
||||
}`}
|
||||
onClick={() => setMethodFilter(methodFilter === method ? '' : method)}
|
||||
>
|
||||
<div className="text-2xl font-bold text-dark-50">{count}</div>
|
||||
<div className="text-sm text-dark-400">{method}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter */}
|
||||
{methodOptions.length > 0 && (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-sm text-dark-400">{t('admin.payments.filterByMethod', 'Фильтр по методу')}:</span>
|
||||
<button
|
||||
onClick={() => setMethodFilter('')}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm transition-all ${
|
||||
methodFilter === ''
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
{t('common.all', 'Все')}
|
||||
</button>
|
||||
{methodOptions.map((method) => (
|
||||
<button
|
||||
key={method}
|
||||
onClick={() => setMethodFilter(method)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm transition-all ${
|
||||
methodFilter === method
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
{method}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payments list */}
|
||||
<div className="card">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : payments?.items && payments.items.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{payments.items.map((payment) => {
|
||||
const paymentKey = `${payment.method}_${payment.id}`
|
||||
const isChecking = checkingPaymentId === paymentKey
|
||||
|
||||
return (
|
||||
<div
|
||||
key={paymentKey}
|
||||
className="p-4 rounded-xl bg-dark-800/30 border border-dark-700/30"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="font-semibold text-dark-100">{payment.method_display}</span>
|
||||
<span className="text-sm px-2 py-0.5 rounded-full bg-dark-700/50 text-dark-300">
|
||||
{payment.status_emoji} {payment.status_text}
|
||||
</span>
|
||||
{payment.is_paid && (
|
||||
<span className="text-sm px-2 py-0.5 rounded-full bg-success-500/20 text-success-400">
|
||||
{t('admin.payments.paid', 'Оплачено')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-lg font-semibold text-dark-50">
|
||||
{formatAmount(payment.amount_rubles)} {currencySymbol}
|
||||
</div>
|
||||
<div className="text-sm text-dark-400 mt-1">
|
||||
ID: <code className="text-dark-300">{payment.identifier}</code>
|
||||
</div>
|
||||
<div className="text-xs text-dark-500 mt-1">
|
||||
{new Date(payment.created_at).toLocaleString()}
|
||||
</div>
|
||||
{/* User info */}
|
||||
{(payment.user_username || payment.user_telegram_id) && (
|
||||
<div className="text-sm text-dark-400 mt-2">
|
||||
<span className="text-dark-500">{t('admin.payments.user', 'Пользователь')}:</span>{' '}
|
||||
{payment.user_username ? (
|
||||
<span className="text-dark-200">@{payment.user_username}</span>
|
||||
) : (
|
||||
<span className="text-dark-300">ID: {payment.user_telegram_id}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{payment.payment_url && (
|
||||
<a
|
||||
href={payment.payment_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-secondary text-xs px-3 py-1.5"
|
||||
>
|
||||
{t('admin.payments.openLink', 'Открыть ссылку')}
|
||||
</a>
|
||||
)}
|
||||
{payment.is_checkable && (
|
||||
<button
|
||||
onClick={() => handleCheckPayment(payment)}
|
||||
disabled={isChecking}
|
||||
className="btn-primary text-xs px-3 py-1.5"
|
||||
>
|
||||
{isChecking ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
{t('admin.payments.checking', 'Проверка...')}
|
||||
</span>
|
||||
) : (
|
||||
t('admin.payments.checkStatus', 'Проверить статус')
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Show result after check */}
|
||||
{checkPaymentMutation.isSuccess && checkPaymentMutation.variables?.paymentId === payment.id && (
|
||||
<div className={`mt-3 p-2 rounded-lg text-sm ${
|
||||
checkPaymentMutation.data?.status_changed
|
||||
? 'bg-success-500/10 border border-success-500/30 text-success-400'
|
||||
: 'bg-dark-700/30 text-dark-400'
|
||||
}`}>
|
||||
{checkPaymentMutation.data?.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-dark-400">{t('admin.payments.noPayments', 'Нет ожидающих платежей')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{payments && payments.pages > 1 && (
|
||||
<div className="mt-4 flex items-center gap-3 flex-wrap text-sm text-dark-500">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={payments.page <= 1}
|
||||
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[100px] ${
|
||||
payments.page <= 1 ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
{t('common.back', 'Назад')}
|
||||
</button>
|
||||
<div className="flex-1 text-center">
|
||||
{t('balance.page', '{current} / {total}', { current: payments.page, total: payments.pages })}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((prev) => Math.min(payments.pages, prev + 1))}
|
||||
disabled={payments.page >= payments.pages}
|
||||
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[100px] ${
|
||||
payments.page >= payments.pages ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
{t('common.next', 'Далее')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
786
src/pages/AdminPromoOffers.tsx
Normal file
786
src/pages/AdminPromoOffers.tsx
Normal file
@@ -0,0 +1,786 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
promoOffersApi,
|
||||
PromoOfferTemplate,
|
||||
PromoOfferTemplateUpdateRequest,
|
||||
PromoOfferLog,
|
||||
TARGET_SEGMENTS,
|
||||
TargetSegment,
|
||||
OFFER_TYPE_CONFIG,
|
||||
OfferType,
|
||||
} from '../api/promoOffers'
|
||||
|
||||
// Icons
|
||||
const GiftIcon = () => (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const EditIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const XIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SendIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ClockIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const UserIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const UsersIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// Helper functions
|
||||
const formatDateTime = (date: string | null): string => {
|
||||
if (!date) return '-'
|
||||
return new Date(date).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const getActionLabel = (action: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
created: 'Создано',
|
||||
claimed: 'Активировано',
|
||||
consumed: 'Использовано',
|
||||
disabled: 'Деактивировано',
|
||||
}
|
||||
return labels[action] || action
|
||||
}
|
||||
|
||||
const getActionColor = (action: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
created: 'bg-blue-500/20 text-blue-400',
|
||||
claimed: 'bg-emerald-500/20 text-emerald-400',
|
||||
consumed: 'bg-purple-500/20 text-purple-400',
|
||||
disabled: 'bg-dark-600 text-dark-400',
|
||||
}
|
||||
return colors[action] || 'bg-dark-600 text-dark-400'
|
||||
}
|
||||
|
||||
const getOfferTypeIcon = (offerType: string): string => {
|
||||
return OFFER_TYPE_CONFIG[offerType as OfferType]?.icon || '🎁'
|
||||
}
|
||||
|
||||
const getOfferTypeLabel = (offerType: string): string => {
|
||||
return OFFER_TYPE_CONFIG[offerType as OfferType]?.label || offerType
|
||||
}
|
||||
|
||||
// Template Edit Modal
|
||||
interface TemplateEditModalProps {
|
||||
template: PromoOfferTemplate
|
||||
onSave: (data: PromoOfferTemplateUpdateRequest) => void
|
||||
onClose: () => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEditModalProps) {
|
||||
const [name, setName] = useState(template.name)
|
||||
const [messageText, setMessageText] = useState(template.message_text)
|
||||
const [buttonText, setButtonText] = useState(template.button_text)
|
||||
const [validHours, setValidHours] = useState(template.valid_hours)
|
||||
const [discountPercent, setDiscountPercent] = useState(template.discount_percent)
|
||||
const [activeDiscountHours, setActiveDiscountHours] = useState(template.active_discount_hours || 0)
|
||||
const [testDurationHours, setTestDurationHours] = useState(template.test_duration_hours || 0)
|
||||
const [isActive, setIsActive] = useState(template.is_active)
|
||||
|
||||
const isTestAccess = template.offer_type === 'test_access'
|
||||
|
||||
const handleSubmit = () => {
|
||||
const data: PromoOfferTemplateUpdateRequest = {
|
||||
name,
|
||||
message_text: messageText,
|
||||
button_text: buttonText,
|
||||
valid_hours: validHours,
|
||||
discount_percent: discountPercent,
|
||||
is_active: isActive,
|
||||
}
|
||||
if (isTestAccess) {
|
||||
data.test_duration_hours = testDurationHours > 0 ? testDurationHours : undefined
|
||||
} else {
|
||||
data.active_discount_hours = activeDiscountHours > 0 ? activeDiscountHours : undefined
|
||||
}
|
||||
onSave(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{getOfferTypeIcon(template.offer_type)}</span>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
Редактирование шаблона
|
||||
</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">Название шаблона</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">Текст сообщения</label>
|
||||
<textarea
|
||||
value={messageText}
|
||||
onChange={e => setMessageText(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">Текст кнопки</label>
|
||||
<input
|
||||
type="text"
|
||||
value={buttonText}
|
||||
onChange={e => setButtonText(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">Срок предложения (часы)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={validHours}
|
||||
onChange={e => setValidHours(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
min={1}
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">Время на активацию</p>
|
||||
</div>
|
||||
|
||||
{!isTestAccess && (
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">Скидка (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={discountPercent}
|
||||
onChange={e => setDiscountPercent(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isTestAccess ? (
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">Длительность доступа (часы)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={testDurationHours}
|
||||
onChange={e => setTestDurationHours(Math.max(0, parseInt(e.target.value) || 0))}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
min={0}
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">0 = по умолчанию</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">Действие скидки (часы)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={activeDiscountHours}
|
||||
onChange={e => setActiveDiscountHours(Math.max(0, parseInt(e.target.value) || 0))}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
min={0}
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">Сколько действует скидка после активации</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsActive(!isActive)}
|
||||
className={`w-10 h-6 rounded-full transition-colors relative ${
|
||||
isActive ? 'bg-accent-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
isActive ? 'left-5' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm text-dark-200">Шаблон активен</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!name.trim() || isLoading}
|
||||
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Send Offer Modal
|
||||
interface SendOfferModalProps {
|
||||
templates: PromoOfferTemplate[]
|
||||
onSend: (templateId: number, target: string | null, userId: number | null) => void
|
||||
onClose: () => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModalProps) {
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(templates[0]?.id || null)
|
||||
const [sendMode, setSendMode] = useState<'segment' | 'user'>('segment')
|
||||
const [selectedTarget, setSelectedTarget] = useState<TargetSegment>('active')
|
||||
const [userId, setUserId] = useState('')
|
||||
|
||||
const selectedTemplate = templates.find(t => t.id === selectedTemplateId)
|
||||
const activeTemplates = templates.filter(t => t.is_active)
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedTemplateId) return
|
||||
if (sendMode === 'user') {
|
||||
const id = parseInt(userId)
|
||||
if (!id) return
|
||||
onSend(selectedTemplateId, null, id)
|
||||
} else {
|
||||
onSend(selectedTemplateId, selectedTarget, null)
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = () => {
|
||||
if (!selectedTemplateId) return false
|
||||
if (sendMode === 'user' && !userId.trim()) return false
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-accent-500/20 rounded-lg">
|
||||
<SendIcon />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
Отправка промопредложения
|
||||
</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Template Selection */}
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-2">Шаблон предложения</label>
|
||||
<div className="space-y-2">
|
||||
{activeTemplates.map(template => (
|
||||
<button
|
||||
key={template.id}
|
||||
onClick={() => setSelectedTemplateId(template.id)}
|
||||
className={`w-full p-3 rounded-lg border text-left transition-colors ${
|
||||
selectedTemplateId === template.id
|
||||
? 'border-accent-500 bg-accent-500/10'
|
||||
: 'border-dark-600 bg-dark-700 hover:border-dark-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">{getOfferTypeIcon(template.offer_type)}</span>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-dark-100">{template.name}</div>
|
||||
<div className="text-sm text-dark-400">
|
||||
{template.discount_percent > 0 && `${template.discount_percent}% скидка`}
|
||||
{template.offer_type === 'test_access' && 'Тестовый доступ'}
|
||||
<span className="mx-1">•</span>
|
||||
{template.valid_hours}ч на активацию
|
||||
</div>
|
||||
</div>
|
||||
{selectedTemplateId === template.id && (
|
||||
<CheckIcon />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Send Mode */}
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-2">Кому отправить</label>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<button
|
||||
onClick={() => setSendMode('segment')}
|
||||
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||
sendMode === 'segment'
|
||||
? 'border-accent-500 bg-accent-500/10 text-accent-400'
|
||||
: 'border-dark-600 text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
<UsersIcon />
|
||||
<span className="ml-2">Сегмент</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSendMode('user')}
|
||||
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||
sendMode === 'user'
|
||||
? 'border-accent-500 bg-accent-500/10 text-accent-400'
|
||||
: 'border-dark-600 text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
<UserIcon />
|
||||
<span className="ml-2">Пользователь</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sendMode === 'segment' ? (
|
||||
<select
|
||||
value={selectedTarget}
|
||||
onChange={e => setSelectedTarget(e.target.value as TargetSegment)}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
>
|
||||
{Object.entries(TARGET_SEGMENTS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={userId}
|
||||
onChange={e => setUserId(e.target.value)}
|
||||
placeholder="Telegram ID или User ID"
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{selectedTemplate && (
|
||||
<div className="p-4 bg-dark-700/50 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-dark-300 mb-2">Предпросмотр</h4>
|
||||
<div className="text-sm text-dark-200 whitespace-pre-wrap">
|
||||
{selectedTemplate.message_text}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<span className="inline-block px-3 py-1.5 bg-accent-500 text-white text-sm rounded-lg">
|
||||
{selectedTemplate.button_text}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid() || isLoading}
|
||||
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<SendIcon />
|
||||
{isLoading ? 'Отправка...' : 'Отправить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Result Modal
|
||||
interface ResultModalProps {
|
||||
title: string
|
||||
message: string
|
||||
isSuccess: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function ResultModal({ title, message, isSuccess, onClose }: ResultModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl p-6 max-w-sm w-full text-center">
|
||||
<div className={`w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center ${
|
||||
isSuccess ? 'bg-emerald-500/20' : 'bg-error-500/20'
|
||||
}`}>
|
||||
{isSuccess ? (
|
||||
<svg className="w-8 h-8 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-8 h-8 text-error-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-dark-100 mb-2">{title}</h3>
|
||||
<p className="text-dark-400 mb-6">{message}</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors"
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminPromoOffers() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'templates' | 'send' | 'logs'>('templates')
|
||||
const [editingTemplate, setEditingTemplate] = useState<PromoOfferTemplate | null>(null)
|
||||
const [showSendModal, setShowSendModal] = useState(false)
|
||||
const [resultModal, setResultModal] = useState<{ title: string; message: string; isSuccess: boolean } | null>(null)
|
||||
|
||||
// Queries
|
||||
const { data: templatesData, isLoading: templatesLoading } = useQuery({
|
||||
queryKey: ['admin-promo-templates'],
|
||||
queryFn: promoOffersApi.getTemplates,
|
||||
})
|
||||
|
||||
const { data: logsData, isLoading: logsLoading } = useQuery({
|
||||
queryKey: ['admin-promo-logs'],
|
||||
queryFn: () => promoOffersApi.getLogs({ limit: 100 }),
|
||||
enabled: activeTab === 'logs',
|
||||
})
|
||||
|
||||
// Mutations
|
||||
const updateTemplateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: PromoOfferTemplateUpdateRequest }) =>
|
||||
promoOffersApi.updateTemplate(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-promo-templates'] })
|
||||
setEditingTemplate(null)
|
||||
},
|
||||
})
|
||||
|
||||
const broadcastMutation = useMutation({
|
||||
mutationFn: promoOffersApi.broadcastOffer,
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] })
|
||||
setShowSendModal(false)
|
||||
setResultModal({
|
||||
title: 'Отправлено!',
|
||||
message: `Промопредложение отправлено ${result.created_offers} пользователям`,
|
||||
isSuccess: true,
|
||||
})
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setResultModal({
|
||||
title: 'Ошибка',
|
||||
message: error.response?.data?.detail || 'Не удалось отправить предложение',
|
||||
isSuccess: false,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const handleSendOffer = (templateId: number, target: string | null, userId: number | null) => {
|
||||
const template = templatesData?.items.find(t => t.id === templateId)
|
||||
if (!template) return
|
||||
|
||||
const data: any = {
|
||||
notification_type: template.offer_type,
|
||||
valid_hours: template.valid_hours,
|
||||
discount_percent: template.discount_percent,
|
||||
effect_type: template.offer_type === 'test_access' ? 'test_access' : 'percent_discount',
|
||||
extra_data: {
|
||||
template_id: template.id,
|
||||
active_discount_hours: template.active_discount_hours,
|
||||
test_duration_hours: template.test_duration_hours,
|
||||
test_squad_uuids: template.test_squad_uuids,
|
||||
},
|
||||
}
|
||||
|
||||
if (target) {
|
||||
data.target = target
|
||||
}
|
||||
if (userId) {
|
||||
data.telegram_id = userId
|
||||
}
|
||||
|
||||
broadcastMutation.mutate(data)
|
||||
}
|
||||
|
||||
const templates = templatesData?.items || []
|
||||
const logs = logsData?.items || []
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-orange-500/20 rounded-lg text-orange-400">
|
||||
<GiftIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">Промопредложения</h1>
|
||||
<p className="text-sm text-dark-400">Шаблоны, рассылка и логи предложений</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSendModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors"
|
||||
>
|
||||
<SendIcon />
|
||||
Отправить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-6 p-1 bg-dark-800 rounded-lg w-fit">
|
||||
<button
|
||||
onClick={() => setActiveTab('templates')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTab === 'templates'
|
||||
? 'bg-dark-700 text-dark-100'
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
Шаблоны ({templates.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('logs')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTab === 'logs'
|
||||
? 'bg-dark-700 text-dark-100'
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
Логи
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Templates Tab */}
|
||||
{activeTab === 'templates' && (
|
||||
<>
|
||||
{templatesLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-dark-400">Нет шаблонов</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{templates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className={`p-4 bg-dark-800 rounded-xl border transition-colors ${
|
||||
template.is_active ? 'border-dark-700' : 'border-dark-700/50 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{getOfferTypeIcon(template.offer_type)}</span>
|
||||
<div>
|
||||
<h3 className="font-medium text-dark-100">{template.name}</h3>
|
||||
<span className="text-xs text-dark-500">{getOfferTypeLabel(template.offer_type)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEditingTemplate(template)}
|
||||
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 hover:text-dark-100 transition-colors"
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
{template.discount_percent > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Скидка:</span>
|
||||
<span className="text-accent-400 font-medium">{template.discount_percent}%</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Срок предложения:</span>
|
||||
<span className="text-dark-200">{template.valid_hours}ч</span>
|
||||
</div>
|
||||
{template.active_discount_hours && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Действие скидки:</span>
|
||||
<span className="text-dark-200">{template.active_discount_hours}ч</span>
|
||||
</div>
|
||||
)}
|
||||
{template.test_duration_hours && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Тестовый доступ:</span>
|
||||
<span className="text-dark-200">{template.test_duration_hours}ч</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-dark-700">
|
||||
<div className="flex items-center gap-2">
|
||||
{template.is_active ? (
|
||||
<span className="px-2 py-0.5 text-xs bg-emerald-500/20 text-emerald-400 rounded">
|
||||
Активен
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 text-xs bg-dark-600 text-dark-400 rounded">
|
||||
Неактивен
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Logs Tab */}
|
||||
{activeTab === 'logs' && (
|
||||
<>
|
||||
{logsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-dark-400">Нет записей</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{logs.map((log: PromoOfferLog) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="p-4 bg-dark-800 rounded-xl border border-dark-700"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-dark-700 rounded-full flex items-center justify-center">
|
||||
<UserIcon />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-dark-100">
|
||||
{log.user?.full_name || log.user?.username || `User #${log.user_id}`}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${getActionColor(log.action)}`}>
|
||||
{getActionLabel(log.action)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-dark-400">
|
||||
{log.source && (
|
||||
<span>{getOfferTypeLabel(log.source)}</span>
|
||||
)}
|
||||
{log.percent && log.percent > 0 && (
|
||||
<span className="ml-2 text-accent-400">{log.percent}%</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-dark-500">
|
||||
<ClockIcon />
|
||||
{formatDateTime(log.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Template Edit Modal */}
|
||||
{editingTemplate && (
|
||||
<TemplateEditModal
|
||||
template={editingTemplate}
|
||||
onSave={(data) => updateTemplateMutation.mutate({ id: editingTemplate.id, data })}
|
||||
onClose={() => setEditingTemplate(null)}
|
||||
isLoading={updateTemplateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Send Offer Modal */}
|
||||
{showSendModal && (
|
||||
<SendOfferModal
|
||||
templates={templates}
|
||||
onSend={handleSendOffer}
|
||||
onClose={() => setShowSendModal(false)}
|
||||
isLoading={broadcastMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Result Modal */}
|
||||
{resultModal && (
|
||||
<ResultModal
|
||||
title={resultModal.title}
|
||||
message={resultModal.message}
|
||||
isSuccess={resultModal.isSuccess}
|
||||
onClose={() => setResultModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1120
src/pages/AdminPromocodes.tsx
Normal file
1120
src/pages/AdminPromocodes.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1090
src/pages/AdminRemnawave.tsx
Normal file
1090
src/pages/AdminRemnawave.tsx
Normal file
File diff suppressed because it is too large
Load Diff
989
src/pages/AdminUsers.tsx
Normal file
989
src/pages/AdminUsers.tsx
Normal file
@@ -0,0 +1,989 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useCurrency } from '../hooks/useCurrency'
|
||||
import {
|
||||
adminUsersApi,
|
||||
type UserListItem,
|
||||
type UserDetailResponse,
|
||||
type UsersStatsResponse,
|
||||
type UserAvailableTariff,
|
||||
type PanelSyncStatusResponse,
|
||||
} from '../api/adminUsers'
|
||||
|
||||
// ============ Icons ============
|
||||
|
||||
const UsersIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SearchIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ChevronLeftIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ChevronRightIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const XMarkIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const RefreshIcon = ({ className = "w-5 h-5" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const PlusIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const MinusIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12h-15" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SyncIcon = ({ className = "w-5 h-5" }: { className?: string }) => (
|
||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const TelegramIcon = () => (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
// ============ Components ============
|
||||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
subtitle?: string
|
||||
color: 'blue' | 'green' | 'yellow' | 'red' | 'purple'
|
||||
}
|
||||
|
||||
function StatCard({ title, value, subtitle, color }: StatCardProps) {
|
||||
const colors = {
|
||||
blue: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
green: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||
yellow: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
red: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
||||
purple: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border p-4 ${colors[color]}`}>
|
||||
<div className="text-2xl font-bold mb-1">{value}</div>
|
||||
<div className="text-sm opacity-80">{title}</div>
|
||||
{subtitle && <div className="text-xs opacity-60 mt-1">{subtitle}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
active: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||
blocked: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
|
||||
deleted: 'bg-dark-600 text-dark-400 border-dark-500',
|
||||
trial: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
expired: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
disabled: 'bg-dark-600 text-dark-400 border-dark-500',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full border ${styles[status] || styles.active}`}>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ User List Component ============
|
||||
|
||||
interface UserRowProps {
|
||||
user: UserListItem
|
||||
onSelect: (user: UserListItem) => void
|
||||
formatAmount: (rubAmount: number) => string
|
||||
}
|
||||
|
||||
function UserRow({ user, onSelect, formatAmount }: UserRowProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(user)}
|
||||
className="flex items-center gap-4 p-4 bg-dark-800/50 rounded-xl border border-dark-700 hover:border-dark-600 hover:bg-dark-800 cursor-pointer transition-all"
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-medium shrink-0">
|
||||
{user.first_name?.[0] || user.username?.[0] || '?'}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-dark-100 truncate">{user.full_name}</span>
|
||||
{user.username && (
|
||||
<span className="text-xs text-dark-500">@{user.username}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-dark-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<TelegramIcon />
|
||||
{user.telegram_id}
|
||||
</span>
|
||||
{user.status !== 'active' && (
|
||||
<StatusBadge status={user.status} />
|
||||
)}
|
||||
{user.has_subscription && user.subscription_status && (
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full border ${
|
||||
user.subscription_status === 'active' ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
|
||||
user.subscription_status === 'trial' ? 'bg-blue-500/20 text-blue-400 border-blue-500/30' :
|
||||
'bg-amber-500/20 text-amber-400 border-amber-500/30'
|
||||
}`}>
|
||||
{user.subscription_status === 'active' ? 'Подписка' :
|
||||
user.subscription_status === 'trial' ? 'Триал' : 'Истекла'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance */}
|
||||
<div className="text-right shrink-0">
|
||||
<div className="font-medium text-dark-100">{formatAmount(user.balance_rubles)}</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{user.purchase_count > 0 ? `${user.purchase_count} покупок` : 'Нет покупок'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChevronRightIcon />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ User Detail Modal ============
|
||||
|
||||
interface UserDetailModalProps {
|
||||
userId: number
|
||||
onClose: () => void
|
||||
onUpdate: () => void
|
||||
}
|
||||
|
||||
function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
const { formatWithCurrency } = useCurrency()
|
||||
const [user, setUser] = useState<UserDetailResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'info' | 'subscription' | 'balance' | 'sync'>('info')
|
||||
const [syncStatus, setSyncStatus] = useState<PanelSyncStatusResponse | null>(null)
|
||||
const [tariffs, setTariffs] = useState<UserAvailableTariff[]>([])
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
|
||||
// Balance form
|
||||
const [balanceAmount, setBalanceAmount] = useState('')
|
||||
const [balanceDescription, setBalanceDescription] = useState('')
|
||||
|
||||
// Subscription form
|
||||
const [subAction, setSubAction] = useState<string>('extend')
|
||||
const [subDays, setSubDays] = useState('30')
|
||||
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null)
|
||||
|
||||
const loadUser = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await adminUsersApi.getUser(userId)
|
||||
setUser(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load user:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [userId])
|
||||
|
||||
const loadSyncStatus = useCallback(async () => {
|
||||
try {
|
||||
const data = await adminUsersApi.getSyncStatus(userId)
|
||||
setSyncStatus(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load sync status:', error)
|
||||
}
|
||||
}, [userId])
|
||||
|
||||
const loadTariffs = useCallback(async () => {
|
||||
try {
|
||||
const data = await adminUsersApi.getAvailableTariffs(userId, true)
|
||||
setTariffs(data.tariffs)
|
||||
} catch (error) {
|
||||
console.error('Failed to load tariffs:', error)
|
||||
}
|
||||
}, [userId])
|
||||
|
||||
useEffect(() => {
|
||||
loadUser()
|
||||
}, [loadUser])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'sync') loadSyncStatus()
|
||||
if (activeTab === 'subscription') loadTariffs()
|
||||
}, [activeTab, loadSyncStatus, loadTariffs])
|
||||
|
||||
const handleUpdateBalance = async (isAdd: boolean) => {
|
||||
if (!balanceAmount) return
|
||||
setActionLoading(true)
|
||||
try {
|
||||
const amount = Math.abs(parseFloat(balanceAmount) * 100)
|
||||
await adminUsersApi.updateBalance(userId, {
|
||||
amount_kopeks: isAdd ? amount : -amount,
|
||||
description: balanceDescription || (isAdd ? 'Начисление админом' : 'Списание админом'),
|
||||
})
|
||||
await loadUser()
|
||||
setBalanceAmount('')
|
||||
setBalanceDescription('')
|
||||
onUpdate()
|
||||
} catch (error) {
|
||||
console.error('Failed to update balance:', error)
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateSubscription = async () => {
|
||||
setActionLoading(true)
|
||||
try {
|
||||
const data: Record<string, unknown> = { action: subAction }
|
||||
if (subAction === 'extend') data.days = parseInt(subDays)
|
||||
if (subAction === 'change_tariff' && selectedTariffId) data.tariff_id = selectedTariffId
|
||||
if (subAction === 'create') {
|
||||
data.days = parseInt(subDays)
|
||||
if (selectedTariffId) data.tariff_id = selectedTariffId
|
||||
}
|
||||
await adminUsersApi.updateSubscription(userId, data as any)
|
||||
await loadUser()
|
||||
onUpdate()
|
||||
} catch (error) {
|
||||
console.error('Failed to update subscription:', error)
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlockUser = async () => {
|
||||
if (!confirm('Заблокировать пользователя?')) return
|
||||
setActionLoading(true)
|
||||
try {
|
||||
await adminUsersApi.blockUser(userId)
|
||||
await loadUser()
|
||||
onUpdate()
|
||||
} catch (error) {
|
||||
console.error('Failed to block user:', error)
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnblockUser = async () => {
|
||||
setActionLoading(true)
|
||||
try {
|
||||
await adminUsersApi.unblockUser(userId)
|
||||
await loadUser()
|
||||
onUpdate()
|
||||
} catch (error) {
|
||||
console.error('Failed to unblock user:', error)
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSyncFromPanel = async () => {
|
||||
setActionLoading(true)
|
||||
try {
|
||||
await adminUsersApi.syncFromPanel(userId, { update_subscription: true, update_traffic: true })
|
||||
await loadUser()
|
||||
await loadSyncStatus()
|
||||
onUpdate()
|
||||
} catch (error) {
|
||||
console.error('Failed to sync from panel:', error)
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSyncToPanel = async () => {
|
||||
setActionLoading(true)
|
||||
try {
|
||||
await adminUsersApi.syncToPanel(userId, { create_if_missing: true })
|
||||
await loadUser()
|
||||
await loadSyncStatus()
|
||||
onUpdate()
|
||||
} catch (error) {
|
||||
console.error('Failed to sync to panel:', error)
|
||||
} finally {
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return '-'
|
||||
return new Date(date).toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div className="bg-dark-800 rounded-2xl p-8">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-dark-800 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg">
|
||||
{user.first_name?.[0] || user.username?.[0] || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-dark-100">{user.full_name}</div>
|
||||
<div className="text-sm text-dark-400 flex items-center gap-2">
|
||||
<TelegramIcon />
|
||||
{user.telegram_id}
|
||||
{user.username && <span>@{user.username}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-dark-700 rounded-lg transition-colors">
|
||||
<XMarkIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-dark-700">
|
||||
{(['info', 'subscription', 'balance', 'sync'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`flex-1 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === tab
|
||||
? 'text-blue-400 border-b-2 border-blue-400'
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
{tab === 'info' && 'Информация'}
|
||||
{tab === 'subscription' && 'Подписка'}
|
||||
{tab === 'balance' && 'Баланс'}
|
||||
{tab === 'sync' && 'Синхронизация'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{/* Info Tab */}
|
||||
{activeTab === 'info' && (
|
||||
<div className="space-y-4">
|
||||
{/* Status */}
|
||||
<div className="flex items-center justify-between p-3 bg-dark-900/50 rounded-xl">
|
||||
<span className="text-dark-400">Статус</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={user.status} />
|
||||
{user.status === 'active' ? (
|
||||
<button
|
||||
onClick={handleBlockUser}
|
||||
disabled={actionLoading}
|
||||
className="px-3 py-1 text-xs bg-rose-500/20 text-rose-400 rounded-lg hover:bg-rose-500/30 transition-colors"
|
||||
>
|
||||
Заблокировать
|
||||
</button>
|
||||
) : user.status === 'blocked' ? (
|
||||
<button
|
||||
onClick={handleUnblockUser}
|
||||
disabled={actionLoading}
|
||||
className="px-3 py-1 text-xs bg-emerald-500/20 text-emerald-400 rounded-lg hover:bg-emerald-500/30 transition-colors"
|
||||
>
|
||||
Разблокировать
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-dark-900/50 rounded-xl">
|
||||
<div className="text-xs text-dark-500 mb-1">Email</div>
|
||||
<div className="text-dark-100">{user.email || '-'}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-900/50 rounded-xl">
|
||||
<div className="text-xs text-dark-500 mb-1">Язык</div>
|
||||
<div className="text-dark-100">{user.language}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-900/50 rounded-xl">
|
||||
<div className="text-xs text-dark-500 mb-1">Регистрация</div>
|
||||
<div className="text-dark-100">{formatDate(user.created_at)}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-900/50 rounded-xl">
|
||||
<div className="text-xs text-dark-500 mb-1">Последняя активность</div>
|
||||
<div className="text-dark-100">{formatDate(user.last_activity)}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-900/50 rounded-xl">
|
||||
<div className="text-xs text-dark-500 mb-1">Всего потрачено</div>
|
||||
<div className="text-dark-100">{formatWithCurrency(user.total_spent_kopeks / 100)}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-900/50 rounded-xl">
|
||||
<div className="text-xs text-dark-500 mb-1">Покупок</div>
|
||||
<div className="text-dark-100">{user.purchase_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Referral */}
|
||||
<div className="p-3 bg-dark-900/50 rounded-xl">
|
||||
<div className="text-sm font-medium text-dark-200 mb-2">Реферальная программа</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">{user.referral.referrals_count}</div>
|
||||
<div className="text-xs text-dark-500">Рефералов</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">{formatWithCurrency(user.referral.total_earnings_kopeks / 100)}</div>
|
||||
<div className="text-xs text-dark-500">Заработано</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">{user.referral.commission_percent || 0}%</div>
|
||||
<div className="text-xs text-dark-500">Комиссия</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Restrictions */}
|
||||
{(user.restriction_topup || user.restriction_subscription) && (
|
||||
<div className="p-3 bg-rose-500/10 border border-rose-500/30 rounded-xl">
|
||||
<div className="text-sm font-medium text-rose-400 mb-2">Ограничения</div>
|
||||
{user.restriction_topup && <div className="text-xs text-rose-300">• Запрет пополнения</div>}
|
||||
{user.restriction_subscription && <div className="text-xs text-rose-300">• Запрет покупки подписки</div>}
|
||||
{user.restriction_reason && <div className="text-xs text-dark-400 mt-1">Причина: {user.restriction_reason}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subscription Tab */}
|
||||
{activeTab === 'subscription' && (
|
||||
<div className="space-y-4">
|
||||
{user.subscription ? (
|
||||
<>
|
||||
{/* Current subscription */}
|
||||
<div className="p-4 bg-dark-900/50 rounded-xl">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-medium text-dark-200">Текущая подписка</span>
|
||||
<StatusBadge status={user.subscription.status} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">Тариф</div>
|
||||
<div className="text-dark-100">{user.subscription.tariff_name || 'Не указан'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">Действует до</div>
|
||||
<div className="text-dark-100">{formatDate(user.subscription.end_date)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">Трафик</div>
|
||||
<div className="text-dark-100">
|
||||
{user.subscription.traffic_used_gb.toFixed(1)} / {user.subscription.traffic_limit_gb} ГБ
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">Устройств</div>
|
||||
<div className="text-dark-100">{user.subscription.device_limit}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="p-4 bg-dark-900/50 rounded-xl">
|
||||
<div className="font-medium text-dark-200 mb-3">Действия</div>
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={subAction}
|
||||
onChange={(e) => setSubAction(e.target.value)}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="extend">Продлить</option>
|
||||
<option value="change_tariff">Сменить тариф</option>
|
||||
<option value="cancel">Отменить</option>
|
||||
<option value="activate">Активировать</option>
|
||||
</select>
|
||||
|
||||
{subAction === 'extend' && (
|
||||
<input
|
||||
type="number"
|
||||
value={subDays}
|
||||
onChange={(e) => setSubDays(e.target.value)}
|
||||
placeholder="Дней"
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-dark-100"
|
||||
/>
|
||||
)}
|
||||
|
||||
{subAction === 'change_tariff' && (
|
||||
<select
|
||||
value={selectedTariffId || ''}
|
||||
onChange={(e) => setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="">Выберите тариф</option>
|
||||
{tariffs.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} {!t.is_available && '(недоступен)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleUpdateSubscription}
|
||||
disabled={actionLoading}
|
||||
className="w-full py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{actionLoading ? 'Применение...' : 'Применить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="p-4 bg-dark-900/50 rounded-xl">
|
||||
<div className="text-center text-dark-400 mb-4">Нет активной подписки</div>
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={selectedTariffId || ''}
|
||||
onChange={(e) => setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)}
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="">Выберите тариф</option>
|
||||
{tariffs.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
value={subDays}
|
||||
onChange={(e) => setSubDays(e.target.value)}
|
||||
placeholder="Дней"
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-dark-100"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSubAction('create')
|
||||
handleUpdateSubscription()
|
||||
}}
|
||||
disabled={actionLoading}
|
||||
className="w-full py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{actionLoading ? 'Создание...' : 'Создать подписку'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Balance Tab */}
|
||||
{activeTab === 'balance' && (
|
||||
<div className="space-y-4">
|
||||
{/* Current balance */}
|
||||
<div className="p-4 bg-gradient-to-r from-blue-500/20 to-purple-500/20 rounded-xl border border-blue-500/30">
|
||||
<div className="text-sm text-dark-400 mb-1">Текущий баланс</div>
|
||||
<div className="text-3xl font-bold text-dark-100">{formatWithCurrency(user.balance_rubles)}</div>
|
||||
</div>
|
||||
|
||||
{/* Add/subtract form */}
|
||||
<div className="p-4 bg-dark-900/50 rounded-xl space-y-3">
|
||||
<input
|
||||
type="number"
|
||||
value={balanceAmount}
|
||||
onChange={(e) => setBalanceAmount(e.target.value)}
|
||||
placeholder="Сумма в рублях"
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-dark-100"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={balanceDescription}
|
||||
onChange={(e) => setBalanceDescription(e.target.value)}
|
||||
placeholder="Описание (опционально)"
|
||||
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-dark-100"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleUpdateBalance(true)}
|
||||
disabled={actionLoading || !balanceAmount}
|
||||
className="flex-1 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<PlusIcon /> Начислить
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdateBalance(false)}
|
||||
disabled={actionLoading || !balanceAmount}
|
||||
className="flex-1 py-2 bg-rose-500 text-white rounded-lg hover:bg-rose-600 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<MinusIcon /> Списать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent transactions */}
|
||||
{user.recent_transactions.length > 0 && (
|
||||
<div className="p-4 bg-dark-900/50 rounded-xl">
|
||||
<div className="font-medium text-dark-200 mb-3">Последние транзакции</div>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{user.recent_transactions.map((tx) => (
|
||||
<div key={tx.id} className="flex items-center justify-between py-2 border-b border-dark-700 last:border-0">
|
||||
<div>
|
||||
<div className="text-sm text-dark-200">{tx.description || tx.type}</div>
|
||||
<div className="text-xs text-dark-500">{formatDate(tx.created_at)}</div>
|
||||
</div>
|
||||
<div className={tx.amount_kopeks >= 0 ? 'text-emerald-400' : 'text-rose-400'}>
|
||||
{tx.amount_kopeks >= 0 ? '+' : ''}{formatWithCurrency(tx.amount_rubles)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sync Tab */}
|
||||
{activeTab === 'sync' && (
|
||||
<div className="space-y-4">
|
||||
{/* Sync status */}
|
||||
{syncStatus && (
|
||||
<div className={`p-4 rounded-xl border ${syncStatus.has_differences ? 'bg-amber-500/10 border-amber-500/30' : 'bg-emerald-500/10 border-emerald-500/30'}`}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{syncStatus.has_differences ? (
|
||||
<span className="text-amber-400 font-medium">Есть расхождения</span>
|
||||
) : (
|
||||
<span className="text-emerald-400 font-medium">Синхронизировано</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{syncStatus.differences.length > 0 && (
|
||||
<div className="space-y-1 mb-3">
|
||||
{syncStatus.differences.map((diff, i) => (
|
||||
<div key={i} className="text-xs text-dark-300">• {diff}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-dark-500 text-xs mb-2">Бот</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Статус:</span>
|
||||
<span className="text-dark-200">{syncStatus.bot_subscription_status || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">До:</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_subscription_end_date
|
||||
? new Date(syncStatus.bot_subscription_end_date).toLocaleDateString('ru-RU')
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Трафик:</span>
|
||||
<span className="text-dark-200">{syncStatus.bot_traffic_used_gb.toFixed(2)} ГБ</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Устройства:</span>
|
||||
<span className="text-dark-200">{syncStatus.bot_device_limit}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Сквады:</span>
|
||||
<span className="text-dark-200">{syncStatus.bot_squads?.length || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-dark-500 text-xs mb-2">Панель</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Статус:</span>
|
||||
<span className="text-dark-200">{syncStatus.panel_status || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">До:</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.panel_expire_at
|
||||
? new Date(syncStatus.panel_expire_at).toLocaleDateString('ru-RU')
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Трафик:</span>
|
||||
<span className="text-dark-200">{syncStatus.panel_traffic_used_gb.toFixed(2)} ГБ</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Устройства:</span>
|
||||
<span className="text-dark-200">{syncStatus.panel_device_limit}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Сквады:</span>
|
||||
<span className="text-dark-200">{syncStatus.panel_squads?.length || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UUID info */}
|
||||
<div className="p-4 bg-dark-900/50 rounded-xl">
|
||||
<div className="text-sm text-dark-400 mb-1">Remnawave UUID</div>
|
||||
<div className="text-dark-100 font-mono text-sm break-all">
|
||||
{syncStatus?.remnawave_uuid || user.remnawave_uuid || 'Не привязан'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync buttons */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSyncFromPanel}
|
||||
disabled={actionLoading}
|
||||
className="flex-1 py-3 bg-blue-500/20 text-blue-400 border border-blue-500/30 rounded-xl hover:bg-blue-500/30 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<SyncIcon className={actionLoading ? 'animate-spin' : ''} />
|
||||
Из панели в бота
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSyncToPanel}
|
||||
disabled={actionLoading}
|
||||
className="flex-1 py-3 bg-purple-500/20 text-purple-400 border border-purple-500/30 rounded-xl hover:bg-purple-500/30 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<SyncIcon className={actionLoading ? 'animate-spin' : ''} />
|
||||
Из бота в панель
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ Main Page ============
|
||||
|
||||
export default function AdminUsers() {
|
||||
const { formatWithCurrency } = useCurrency()
|
||||
|
||||
const [users, setUsers] = useState<UserListItem[]>([])
|
||||
const [stats, setStats] = useState<UsersStatsResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('')
|
||||
const [sortBy, setSortBy] = useState<string>('created_at')
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
|
||||
|
||||
const limit = 20
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const params: Record<string, unknown> = { offset, limit, sort_by: sortBy }
|
||||
if (search) params.search = search
|
||||
if (statusFilter) params.status = statusFilter
|
||||
|
||||
const data = await adminUsersApi.getUsers(params as any)
|
||||
setUsers(data.users)
|
||||
setTotal(data.total)
|
||||
} catch (error) {
|
||||
console.error('Failed to load users:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [offset, search, statusFilter, sortBy])
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const data = await adminUsersApi.getStats()
|
||||
setStats(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
}, [loadUsers])
|
||||
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
}, [loadStats])
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setOffset(0)
|
||||
loadUsers()
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
const currentPage = Math.floor(offset / limit) + 1
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-indigo-500/20 rounded-xl">
|
||||
<UsersIcon className="w-7 h-7 text-indigo-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-dark-100">Пользователи</h1>
|
||||
<p className="text-sm text-dark-400">Управление пользователями бота</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { loadUsers(); loadStats() }}
|
||||
className="p-2 hover:bg-dark-700 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshIcon className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 mb-6">
|
||||
<StatCard title="Всего" value={stats.total_users} color="blue" />
|
||||
<StatCard title="Активных" value={stats.active_users} color="green" />
|
||||
<StatCard title="С подпиской" value={stats.users_with_active_subscription} color="purple" />
|
||||
<StatCard title="Новых сегодня" value={stats.new_today} color="yellow" />
|
||||
<StatCard title="Заблокировано" value={stats.blocked_users} color="red" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-4">
|
||||
<form onSubmit={handleSearch} className="flex-1">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск по ID, имени, username..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-dark-800 border border-dark-700 rounded-xl text-dark-100 placeholder-dark-500 focus:border-dark-600 focus:outline-none"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
||||
<SearchIcon />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setOffset(0) }}
|
||||
className="bg-dark-800 border border-dark-700 rounded-xl px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="">Все статусы</option>
|
||||
<option value="active">Активные</option>
|
||||
<option value="blocked">Заблокированные</option>
|
||||
<option value="deleted">Удалённые</option>
|
||||
</select>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => { setSortBy(e.target.value); setOffset(0) }}
|
||||
className="bg-dark-800 border border-dark-700 rounded-xl px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="created_at">По дате</option>
|
||||
<option value="balance">По балансу</option>
|
||||
<option value="last_activity">По активности</option>
|
||||
<option value="total_spent">По расходам</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Users list */}
|
||||
<div className="space-y-2 mb-4">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="text-center py-12 text-dark-400">
|
||||
Пользователи не найдены
|
||||
</div>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
onSelect={(u) => setSelectedUserId(u.id)}
|
||||
formatAmount={(amount) => formatWithCurrency(amount)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-dark-400">
|
||||
Показано {offset + 1}-{Math.min(offset + limit, total)} из {total}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setOffset(Math.max(0, offset - limit))}
|
||||
disabled={offset === 0}
|
||||
className="p-2 bg-dark-800 border border-dark-700 rounded-lg disabled:opacity-50 hover:bg-dark-700 transition-colors"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</button>
|
||||
<span className="px-3 py-2 text-dark-300">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setOffset(offset + limit)}
|
||||
disabled={offset + limit >= total}
|
||||
className="p-2 bg-dark-800 border border-dark-700 rounded-lg disabled:opacity-50 hover:bg-dark-700 transition-colors"
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User detail modal */}
|
||||
{selectedUserId && (
|
||||
<UserDetailModal
|
||||
userId={selectedUserId}
|
||||
onClose={() => setSelectedUserId(null)}
|
||||
onUpdate={() => { loadUsers(); loadStats() }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { balanceApi } from '../api/balance'
|
||||
import { wheelApi } from '../api/wheel'
|
||||
import ConnectionModal from '../components/ConnectionModal'
|
||||
import Onboarding, { useOnboarding } from '../components/Onboarding'
|
||||
import PromoOffersSection from '../components/PromoOffersSection'
|
||||
import { useCurrency } from '../hooks/useCurrency'
|
||||
|
||||
// Icons
|
||||
@@ -398,6 +399,9 @@ export default function Dashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Promo Offers */}
|
||||
<PromoOffersSection />
|
||||
|
||||
{/* Fortune Wheel Banner */}
|
||||
{wheelConfig?.is_enabled && (
|
||||
<Link
|
||||
|
||||
@@ -2,247 +2,341 @@ import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { referralApi } from '../api/referral'
|
||||
import { brandingApi } from '../api/branding'
|
||||
import { useCurrency } from '../hooks/useCurrency'
|
||||
|
||||
const CopyIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||
</svg>
|
||||
<svg
|
||||
className='w-4 h-4'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
d='M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
<svg
|
||||
className='w-4 h-4'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
d='M4.5 12.75l6 6 9-13.5'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ShareIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8l5-5m0 0l5 5m-5-5v12" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 15v3a2 2 0 002 2h12a2 2 0 002-2v-3" />
|
||||
</svg>
|
||||
<svg
|
||||
className='w-4 h-4'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
d='M7 8l5-5m0 0l5 5m-5-5v12'
|
||||
/>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
d='M4 15v3a2 2 0 002 2h12a2 2 0 002-2v-3'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export default function Referral() {
|
||||
const { t } = useTranslation()
|
||||
const { formatAmount, currencySymbol, formatPositive } = useCurrency()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const { formatAmount, currencySymbol, formatPositive } = useCurrency()
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const { data: info, isLoading } = useQuery({
|
||||
queryKey: ['referral-info'],
|
||||
queryFn: referralApi.getReferralInfo,
|
||||
})
|
||||
const { data: info, isLoading } = useQuery({
|
||||
queryKey: ['referral-info'],
|
||||
queryFn: referralApi.getReferralInfo,
|
||||
})
|
||||
|
||||
const { data: terms } = useQuery({
|
||||
queryKey: ['referral-terms'],
|
||||
queryFn: referralApi.getReferralTerms,
|
||||
})
|
||||
const { data: terms } = useQuery({
|
||||
queryKey: ['referral-terms'],
|
||||
queryFn: referralApi.getReferralTerms,
|
||||
})
|
||||
|
||||
const { data: referralList } = useQuery({
|
||||
queryKey: ['referral-list'],
|
||||
queryFn: () => referralApi.getReferralList({ per_page: 10 }),
|
||||
})
|
||||
const { data: referralList } = useQuery({
|
||||
queryKey: ['referral-list'],
|
||||
queryFn: () => referralApi.getReferralList({ per_page: 10 }),
|
||||
})
|
||||
|
||||
const { data: earnings } = useQuery({
|
||||
queryKey: ['referral-earnings'],
|
||||
queryFn: () => referralApi.getReferralEarnings({ per_page: 10 }),
|
||||
})
|
||||
const { data: earnings } = useQuery({
|
||||
queryKey: ['referral-earnings'],
|
||||
queryFn: () => referralApi.getReferralEarnings({ per_page: 10 }),
|
||||
})
|
||||
|
||||
const copyLink = () => {
|
||||
if (info?.referral_link) {
|
||||
navigator.clipboard.writeText(info.referral_link)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
const { data: branding } = useQuery({
|
||||
queryKey: ['branding'],
|
||||
queryFn: brandingApi.getBranding,
|
||||
staleTime: 60000,
|
||||
})
|
||||
|
||||
const shareLink = () => {
|
||||
if (!info?.referral_link) return
|
||||
const shareText = t('referral.shareMessage', {
|
||||
percent: info?.commission_percent || 0,
|
||||
})
|
||||
const copyLink = () => {
|
||||
if (info?.referral_link) {
|
||||
navigator.clipboard.writeText(info.referral_link)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
if (navigator.share) {
|
||||
navigator
|
||||
.share({
|
||||
title: t('referral.title'),
|
||||
text: shareText,
|
||||
url: info.referral_link,
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore cancellation errors
|
||||
})
|
||||
return
|
||||
}
|
||||
const shareLink = () => {
|
||||
if (!info?.referral_link) return
|
||||
const shareText = t('referral.shareMessage', {
|
||||
percent: info?.commission_percent || 0,
|
||||
botName: branding?.name || import.meta.env.VITE_APP_NAME || 'Cabinet',
|
||||
})
|
||||
|
||||
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(
|
||||
info.referral_link
|
||||
)}&text=${encodeURIComponent(shareText)}`
|
||||
window.open(telegramUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
if (navigator.share) {
|
||||
navigator
|
||||
.share({
|
||||
title: t('referral.title'),
|
||||
text: shareText,
|
||||
url: info.referral_link,
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore cancellation errors
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-64">
|
||||
<div className="w-10 h-10 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(
|
||||
info.referral_link
|
||||
)}&text=${encodeURIComponent(shareText)}`
|
||||
window.open(telegramUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('referral.title')}</h1>
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className='flex items-center justify-center min-h-64'>
|
||||
<div className='w-10 h-10 border-2 border-accent-500 border-t-transparent rounded-full animate-spin' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<div className="card">
|
||||
<div className="text-sm text-dark-400">{t('referral.stats.totalReferrals')}</div>
|
||||
<div className="stat-value mt-1">{info?.total_referrals || 0}</div>
|
||||
<div className="text-sm text-dark-500 mt-1">
|
||||
{info?.active_referrals || 0} {t('referral.stats.activeReferrals').toLowerCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="text-sm text-dark-400">{t('referral.stats.totalEarnings')}</div>
|
||||
<div className="stat-value text-success-400 mt-1">
|
||||
{formatPositive(info?.total_earnings_rubles || 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card col-span-2 sm:col-span-1">
|
||||
<div className="text-sm text-dark-400">{t('referral.stats.commissionRate')}</div>
|
||||
<div className="stat-value text-accent-400 mt-1">
|
||||
{info?.commission_percent || 0}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<h1 className='text-2xl sm:text-3xl font-bold text-dark-50'>
|
||||
{t('referral.title')}
|
||||
</h1>
|
||||
|
||||
{/* Referral Link */}
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.yourLink')}</h2>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={info?.referral_link || ''}
|
||||
className="input flex-1"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={copyLink}
|
||||
disabled={!info?.referral_link}
|
||||
className={`btn-primary px-5 ${
|
||||
copied ? 'bg-success-500 hover:bg-success-500' : ''
|
||||
} ${!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
<span className="ml-2">{copied ? t('referral.copied') : t('referral.copyLink')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={shareLink}
|
||||
disabled={!info?.referral_link}
|
||||
className={`btn-secondary px-5 flex items-center ${
|
||||
!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
<ShareIcon />
|
||||
<span className="ml-2">{t('referral.shareButton')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-dark-500">
|
||||
{t('referral.shareHint', { percent: info?.commission_percent || 0 })}
|
||||
</p>
|
||||
</div>
|
||||
{/* Stats Cards */}
|
||||
<div className='grid grid-cols-2 sm:grid-cols-3 gap-4'>
|
||||
<div className='card'>
|
||||
<div className='text-sm text-dark-400'>
|
||||
{t('referral.stats.totalReferrals')}
|
||||
</div>
|
||||
<div className='stat-value mt-1'>{info?.total_referrals || 0}</div>
|
||||
<div className='text-sm text-dark-500 mt-1'>
|
||||
{info?.active_referrals || 0}{' '}
|
||||
{t('referral.stats.activeReferrals').toLowerCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className='card'>
|
||||
<div className='text-sm text-dark-400'>
|
||||
{t('referral.stats.totalEarnings')}
|
||||
</div>
|
||||
<div className='stat-value text-success-400 mt-1'>
|
||||
{formatPositive(info?.total_earnings_rubles || 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='card col-span-2 sm:col-span-1'>
|
||||
<div className='text-sm text-dark-400'>
|
||||
{t('referral.stats.commissionRate')}
|
||||
</div>
|
||||
<div className='stat-value text-accent-400 mt-1'>
|
||||
{info?.commission_percent || 0}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Program Terms */}
|
||||
{terms && (
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.terms.title')}</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="p-3 rounded-xl bg-dark-800/30">
|
||||
<div className="text-sm text-dark-500">{t('referral.terms.commission')}</div>
|
||||
<div className="text-lg font-semibold text-dark-100 mt-1">{terms.commission_percent}%</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-dark-800/30">
|
||||
<div className="text-sm text-dark-500">{t('referral.terms.minTopup')}</div>
|
||||
<div className="text-lg font-semibold text-dark-100 mt-1">{formatAmount(terms.minimum_topup_rubles)} {currencySymbol}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-dark-800/30">
|
||||
<div className="text-sm text-dark-500">{t('referral.terms.newUserBonus')}</div>
|
||||
<div className="text-lg font-semibold text-success-400 mt-1">{formatPositive(terms.first_topup_bonus_rubles)}</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-dark-800/30">
|
||||
<div className="text-sm text-dark-500">{t('referral.terms.inviterBonus')}</div>
|
||||
<div className="text-lg font-semibold text-success-400 mt-1">{formatPositive(terms.inviter_bonus_rubles)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Referral Link */}
|
||||
<div className='card'>
|
||||
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
|
||||
{t('referral.yourLink')}
|
||||
</h2>
|
||||
<div className='flex flex-col gap-2 sm:flex-row'>
|
||||
<input
|
||||
type='text'
|
||||
readOnly
|
||||
value={info?.referral_link || ''}
|
||||
className='input flex-1'
|
||||
/>
|
||||
<div className='flex gap-2'>
|
||||
<button
|
||||
onClick={copyLink}
|
||||
disabled={!info?.referral_link}
|
||||
className={`btn-primary px-5 ${
|
||||
copied ? 'bg-success-500 hover:bg-success-500' : ''
|
||||
} ${!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
<span className='ml-2'>
|
||||
{copied ? t('referral.copied') : t('referral.copyLink')}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={shareLink}
|
||||
disabled={!info?.referral_link}
|
||||
className={`btn-secondary px-5 flex items-center ${
|
||||
!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
<ShareIcon />
|
||||
<span className='ml-2'>{t('referral.shareButton')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className='mt-3 text-sm text-dark-500'>
|
||||
{t('referral.shareHint', { percent: info?.commission_percent || 0 })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Referrals List */}
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.yourReferrals')}</h2>
|
||||
{referralList?.items && referralList.items.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{referralList.items.map((ref) => (
|
||||
<div
|
||||
key={ref.id}
|
||||
className="flex items-center justify-between p-3 rounded-xl bg-dark-800/30 border border-dark-700/30"
|
||||
>
|
||||
<div>
|
||||
<div className="text-dark-100 font-medium">
|
||||
{ref.first_name || ref.username || `User #${ref.id}`}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500 mt-0.5">
|
||||
{new Date(ref.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
{ref.has_paid ? (
|
||||
<span className="badge-success">{t('referral.status.paid')}</span>
|
||||
) : (
|
||||
<span className="badge-neutral">{t('referral.status.pending')}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-dark-400">{t('referral.noReferrals')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Program Terms */}
|
||||
{terms && (
|
||||
<div className='card'>
|
||||
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
|
||||
{t('referral.terms.title')}
|
||||
</h2>
|
||||
<div className='grid grid-cols-2 md:grid-cols-4 gap-4'>
|
||||
<div className='p-3 rounded-xl bg-dark-800/30'>
|
||||
<div className='text-sm text-dark-500'>
|
||||
{t('referral.terms.commission')}
|
||||
</div>
|
||||
<div className='text-lg font-semibold text-dark-100 mt-1'>
|
||||
{terms.commission_percent}%
|
||||
</div>
|
||||
</div>
|
||||
<div className='p-3 rounded-xl bg-dark-800/30'>
|
||||
<div className='text-sm text-dark-500'>
|
||||
{t('referral.terms.minTopup')}
|
||||
</div>
|
||||
<div className='text-lg font-semibold text-dark-100 mt-1'>
|
||||
{formatAmount(terms.minimum_topup_rubles)} {currencySymbol}
|
||||
</div>
|
||||
</div>
|
||||
<div className='p-3 rounded-xl bg-dark-800/30'>
|
||||
<div className='text-sm text-dark-500'>
|
||||
{t('referral.terms.newUserBonus')}
|
||||
</div>
|
||||
<div className='text-lg font-semibold text-success-400 mt-1'>
|
||||
{formatPositive(terms.first_topup_bonus_rubles)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='p-3 rounded-xl bg-dark-800/30'>
|
||||
<div className='text-sm text-dark-500'>
|
||||
{t('referral.terms.inviterBonus')}
|
||||
</div>
|
||||
<div className='text-lg font-semibold text-success-400 mt-1'>
|
||||
{formatPositive(terms.inviter_bonus_rubles)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Earnings History */}
|
||||
{earnings?.items && earnings.items.length > 0 && (
|
||||
<div className="card">
|
||||
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.earningsHistory')}</h2>
|
||||
<div className="space-y-3">
|
||||
{earnings.items.map((earning) => (
|
||||
<div
|
||||
key={earning.id}
|
||||
className="flex items-center justify-between p-3 rounded-xl bg-dark-800/30 border border-dark-700/30"
|
||||
>
|
||||
<div>
|
||||
<div className="text-dark-100">
|
||||
{earning.referral_first_name || earning.referral_username || 'Referral'}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500 mt-0.5">
|
||||
{t(`referral.reasons.${earning.reason}`, earning.reason)} • {new Date(earning.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-success-400 font-semibold">
|
||||
{formatPositive(earning.amount_rubles)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
{/* Referrals List */}
|
||||
<div className='card'>
|
||||
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
|
||||
{t('referral.yourReferrals')}
|
||||
</h2>
|
||||
{referralList?.items && referralList.items.length > 0 ? (
|
||||
<div className='space-y-3'>
|
||||
{referralList.items.map(ref => (
|
||||
<div
|
||||
key={ref.id}
|
||||
className='flex items-center justify-between p-3 rounded-xl bg-dark-800/30 border border-dark-700/30'
|
||||
>
|
||||
<div>
|
||||
<div className='text-dark-100 font-medium'>
|
||||
{ref.first_name || ref.username || `User #${ref.id}`}
|
||||
</div>
|
||||
<div className='text-xs text-dark-500 mt-0.5'>
|
||||
{new Date(ref.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
{ref.has_paid ? (
|
||||
<span className='badge-success'>
|
||||
{t('referral.status.paid')}
|
||||
</span>
|
||||
) : (
|
||||
<span className='badge-neutral'>
|
||||
{t('referral.status.pending')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-center py-12'>
|
||||
<div className='w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center'>
|
||||
<svg
|
||||
className='w-8 h-8 text-dark-500'
|
||||
fill='none'
|
||||
viewBox='0 0 24 24'
|
||||
stroke='currentColor'
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
d='M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className='text-dark-400'>{t('referral.noReferrals')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Earnings History */}
|
||||
{earnings?.items && earnings.items.length > 0 && (
|
||||
<div className='card'>
|
||||
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
|
||||
{t('referral.earningsHistory')}
|
||||
</h2>
|
||||
<div className='space-y-3'>
|
||||
{earnings.items.map(earning => (
|
||||
<div
|
||||
key={earning.id}
|
||||
className='flex items-center justify-between p-3 rounded-xl bg-dark-800/30 border border-dark-700/30'
|
||||
>
|
||||
<div>
|
||||
<div className='text-dark-100'>
|
||||
{earning.referral_first_name ||
|
||||
earning.referral_username ||
|
||||
'Referral'}
|
||||
</div>
|
||||
<div className='text-xs text-dark-500 mt-0.5'>
|
||||
{t(`referral.reasons.${earning.reason}`, earning.reason)} •{' '}
|
||||
{new Date(earning.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className='text-success-400 font-semibold'>
|
||||
{formatPositive(earning.amount_rubles)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -486,3 +486,33 @@ export interface AppConfig {
|
||||
supportUrl?: string
|
||||
}
|
||||
}
|
||||
|
||||
// Pending payment types
|
||||
export interface PendingPayment {
|
||||
id: number
|
||||
method: string
|
||||
method_display: string
|
||||
identifier: string
|
||||
amount_kopeks: number
|
||||
amount_rubles: number
|
||||
status: string
|
||||
status_emoji: string
|
||||
status_text: string
|
||||
is_paid: boolean
|
||||
is_checkable: boolean
|
||||
created_at: string
|
||||
expires_at: string | null
|
||||
payment_url: string | null
|
||||
user_id?: number
|
||||
user_telegram_id?: number
|
||||
user_username?: string | null
|
||||
}
|
||||
|
||||
export interface ManualCheckResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
payment: PendingPayment | null
|
||||
status_changed: boolean
|
||||
old_status: string | null
|
||||
new_status: string | null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user