Merge pull request #9 from BEDOLAGA-DEV/main

w
This commit is contained in:
Egor
2026-01-18 05:14:15 +03:00
committed by GitHub
31 changed files with 12844 additions and 3891 deletions

View File

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

View File

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

View File

@@ -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 в прокси конфигурации правильное

View File

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

View File

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

View File

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

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

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

View File

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

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

File diff suppressed because it is too large Load Diff

1090
src/pages/AdminRemnawave.tsx Normal file

File diff suppressed because it is too large Load Diff

989
src/pages/AdminUsers.tsx Normal file
View 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>
)
}

View File

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

View File

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

View File

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