diff --git a/.env.example b/.env.example index 4e88994..76dcdc8 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/Dockerfile b/Dockerfile index 728da55..8293e10 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 10209ba..a9b2a1f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Bedolaga Cabinet - Web Interface -Современный веб-интерфейс личного кабинета для VPN бота на базе [Remnawave Bedolaga Telegram Bot](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot). +Современный веб-интерфейс личного кабинета для VPN бота на базе [Remnawave Bedolaga Telegram Bot V3.0.0+](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot). ## Возможности @@ -70,7 +70,7 @@ VITE_APP_NAME=My VPN Cabinet VITE_APP_LOGO=V # Порт для Docker контейнера -CABINET_PORT=3000 +CABINET_PORT=3020 ``` #### 3. Запуск в Docker @@ -101,6 +101,7 @@ CABINET_ALLOWED_ORIGINS=http://localhost:3000,https://cabinet.yourdomain.com ## Настройка прокси для production Frontend - это статические файлы (HTML, JS, CSS). Для работы нужно: + 1. Раздавать статику через веб-сервер 2. Проксировать `/api/*` запросы на backend бота @@ -153,8 +154,8 @@ services: - ./cabinet-dist:/srv/cabinet:ro - caddy_data:/data ports: - - "80:80" - - "443:443" + - '80:80' + - '443:443' networks: - bot_network ``` @@ -164,6 +165,7 @@ services: Если хотите использовать готовый Docker контейнер с nginx внутри. **⚠️ Важно:** + - Порт `80` в примерах - это **внутренний порт контейнера** (nginx внутри), не хост-порт! - Контейнеры должны быть в одной Docker сети для связи друг с другом - Имена контейнеров используются как DNS внутри Docker сети @@ -194,11 +196,12 @@ services: networks: bot_network: - external: true # Используем существующую сеть - name: remnawave-bedolaga-telegram-bot_bot_network # Пример для bedolaga bot + external: true # Используем существующую сеть + name: remnawave-bedolaga-telegram-bot_bot_network # Пример для bedolaga bot ``` **Важно:** Замените имя сети на вашу: + - Если у вас bot + caddy: используйте сеть бота (обычно `<название_проекта>_bot_network`) - Если отдельный Caddy: узнайте через `docker network ls` - Если используете Traefik: обычно `traefik` или `web` @@ -212,6 +215,7 @@ docker compose up -d **Шаг 4:** Добавьте в конфигурацию Caddy/Nginx: Caddy проксирует на контейнер: + ```caddyfile cabinet.yourdomain.com { # API на backend @@ -228,6 +232,7 @@ cabinet.yourdomain.com { ``` Nginx (добавьте в существующий конфиг): + ```nginx server { listen 443 ssl http2; @@ -269,6 +274,7 @@ docker exec nginx -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 в прокси конфигурации правильное diff --git a/docker-compose.yml b/docker-compose.yml index 2a5c551..2ffd9bb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..1706b3b --- /dev/null +++ b/nginx.conf @@ -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"; + } +} diff --git a/package-lock.json b/package-lock.json index d38b7b8..4e69810 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@lottiefiles/dotlottie-react": "^0.8.0", "@tanstack/react-query": "^5.8.0", "axios": "^1.6.0", + "dompurify": "^3.3.1", "i18next": "^23.7.0", "i18next-browser-languagedetector": "^7.2.0", "react": "^18.2.0", @@ -20,6 +21,7 @@ "zustand": "^4.4.0" }, "devDependencies": { + "@types/dompurify": "^3.0.5", "@types/react": "^18.2.37", "@types/react-dom": "^18.2.15", "@typescript-eslint/eslint-plugin": "^6.10.0", @@ -1402,6 +1404,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1452,6 +1464,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", @@ -2207,6 +2226,15 @@ "node": ">=6.0.0" } }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index b9de959..7fb0d2c 100644 --- a/package.json +++ b/package.json @@ -12,17 +12,19 @@ }, "dependencies": { "@lottiefiles/dotlottie-react": "^0.8.0", + "@tanstack/react-query": "^5.8.0", "axios": "^1.6.0", + "dompurify": "^3.3.1", "i18next": "^23.7.0", "i18next-browser-languagedetector": "^7.2.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^13.5.0", "react-router-dom": "^6.20.0", - "zustand": "^4.4.0", - "@tanstack/react-query": "^5.8.0" + "zustand": "^4.4.0" }, "devDependencies": { + "@types/dompurify": "^3.0.5", "@types/react": "^18.2.37", "@types/react-dom": "^18.2.15", "@typescript-eslint/eslint-plugin": "^6.10.0", diff --git a/src/App.tsx b/src/App.tsx index bc7f948..c99b572 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,6 +30,9 @@ 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() @@ -262,6 +265,30 @@ function App() { } /> + + + + } + /> + + + + } + /> + + + + } + /> {/* Catch all */} } /> diff --git a/src/api/adminPayments.ts b/src/api/adminPayments.ts new file mode 100644 index 0000000..a169517 --- /dev/null +++ b/src/api/adminPayments.ts @@ -0,0 +1,39 @@ +import apiClient from './client' +import type { PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types' + +export interface PaymentsStats { + total_pending: number + by_method: Record +} + +export const adminPaymentsApi = { + // Get all pending payments (admin) + getPendingPayments: async (params?: { + page?: number + per_page?: number + method_filter?: string + }): Promise> => { + const response = await apiClient.get>('/cabinet/admin/payments', { + params, + }) + return response.data + }, + + // Get payments statistics + getStats: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/payments/stats') + return response.data + }, + + // Get specific payment details + getPayment: async (method: string, paymentId: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/payments/${method}/${paymentId}`) + return response.data + }, + + // Manually check payment status + checkPaymentStatus: async (method: string, paymentId: number): Promise => { + const response = await apiClient.post(`/cabinet/admin/payments/${method}/${paymentId}/check`) + return response.data + }, +} diff --git a/src/api/adminRemnawave.ts b/src/api/adminRemnawave.ts new file mode 100644 index 0000000..a044e03 --- /dev/null +++ b/src/api/adminRemnawave.ts @@ -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 +} + +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 + server_info: ServerInfo + bandwidth: Bandwidth + traffic_periods: TrafficPeriods + nodes_realtime: Record[] + nodes_weekly: Record[] + 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 + usage_history: Record[] + 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[] + 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 +} + +// 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[] + 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 + last_server_stats?: Record +} + +export interface AutoSyncRunResponse { + started: boolean + success?: boolean + error?: string + user_stats?: Record + server_stats?: Record + reason?: string +} + +// Sync +export interface SyncResponse { + success: boolean + message?: string + data?: Record +} + +// ============ API ============ + +export const adminRemnawaveApi = { + // Status & Connection + getStatus: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/remnawave/status') + return response.data + }, + + // System Statistics + getSystemStats: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/remnawave/system') + return response.data + }, + + // Nodes + getNodes: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/remnawave/nodes') + return response.data + }, + + getNodesOverview: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/remnawave/nodes/overview') + return response.data + }, + + getNodesRealtime: async (): Promise[]> => { + const response = await apiClient.get('/cabinet/admin/remnawave/nodes/realtime') + return response.data + }, + + getNode: async (uuid: string): Promise => { + const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}`) + return response.data + }, + + getNodeStatistics: async (uuid: string): Promise => { + const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}/statistics`) + return response.data + }, + + nodeAction: async (uuid: string, action: 'enable' | 'disable' | 'restart'): Promise => { + const response = await apiClient.post(`/cabinet/admin/remnawave/nodes/${uuid}/action`, { action }) + return response.data + }, + + restartAllNodes: async (): Promise => { + const response = await apiClient.post('/cabinet/admin/remnawave/nodes/restart-all') + return response.data + }, + + // Squads + getSquads: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/remnawave/squads') + return response.data + }, + + getSquad: async (uuid: string): Promise => { + const response = await apiClient.get(`/cabinet/admin/remnawave/squads/${uuid}`) + return response.data + }, + + createSquad: async (data: { name: string; inbound_uuids?: string[] }): Promise => { + const response = await apiClient.post('/cabinet/admin/remnawave/squads', data) + return response.data + }, + + updateSquad: async (uuid: string, data: { name?: string; inbound_uuids?: string[] }): Promise => { + const response = await apiClient.patch(`/cabinet/admin/remnawave/squads/${uuid}`, data) + return response.data + }, + + deleteSquad: async (uuid: string): Promise => { + 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 => { + const response = await apiClient.post(`/cabinet/admin/remnawave/squads/${uuid}/action`, data) + return response.data + }, + + // Migration + getMigrationPreview: async (uuid: string): Promise => { + const response = await apiClient.get(`/cabinet/admin/remnawave/squads/${uuid}/migration-preview`) + return response.data + }, + + migrateSquad: async (sourceUuid: string, targetUuid: string): Promise => { + const response = await apiClient.post('/cabinet/admin/remnawave/squads/migrate', { + source_uuid: sourceUuid, + target_uuid: targetUuid, + }) + return response.data + }, + + // Inbounds + getInbounds: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/remnawave/inbounds') + return response.data + }, + + // Auto Sync + getAutoSyncStatus: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/remnawave/sync/auto/status') + return response.data + }, + + toggleAutoSync: async (enabled: boolean): Promise => { + const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/toggle', { enabled }) + return response.data + }, + + runAutoSync: async (): Promise => { + 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 => { + const response = await apiClient.post('/cabinet/admin/remnawave/sync/from-panel', { mode }) + return response.data + }, + + syncToPanel: async (): Promise => { + const response = await apiClient.post('/cabinet/admin/remnawave/sync/to-panel') + return response.data + }, + + syncServers: async (): Promise => { + const response = await apiClient.post('/cabinet/admin/remnawave/sync/servers') + return response.data + }, + + validateSubscriptions: async (): Promise => { + const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/validate') + return response.data + }, + + cleanupSubscriptions: async (): Promise => { + const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/cleanup') + return response.data + }, + + syncSubscriptionStatuses: async (): Promise => { + const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/statuses') + return response.data + }, + + getSyncRecommendations: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/remnawave/sync/recommendations') + return response.data + }, +} + +export default adminRemnawaveApi diff --git a/src/api/balance.ts b/src/api/balance.ts index b0a4d82..d2aeef4 100644 --- a/src/api/balance.ts +++ b/src/api/balance.ts @@ -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> => { + const response = await apiClient.get>('/cabinet/balance/pending-payments', { + params, + }) + return response.data + }, + + // Get specific pending payment details + getPendingPayment: async (method: string, paymentId: number): Promise => { + const response = await apiClient.get(`/cabinet/balance/pending-payments/${method}/${paymentId}`) + return response.data + }, + + // Manually check payment status + checkPaymentStatus: async (method: string, paymentId: number): Promise => { + const response = await apiClient.post(`/cabinet/balance/pending-payments/${method}/${paymentId}/check`) + return response.data + }, } diff --git a/src/api/client.ts b/src/api/client.ts index 9a9221a..808184c 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,27 +1,47 @@ import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios' +import { tokenStorage, isTokenExpired, tokenRefreshManager, safeRedirectToLogin } from '../utils/token' const API_BASE_URL = import.meta.env.VITE_API_URL || '/api' -const TELEGRAM_INIT_STORAGE_KEY = 'telegram_init_data' +// Настраиваем endpoint для refresh +tokenRefreshManager.setRefreshEndpoint(`${API_BASE_URL}/cabinet/auth/refresh`) + +// CSRF token management +const CSRF_COOKIE_NAME = 'csrf_token' +const CSRF_HEADER_NAME = 'X-CSRF-Token' + +function getCsrfToken(): string | null { + if (typeof document === 'undefined') return null + const match = document.cookie.match(new RegExp(`(^| )${CSRF_COOKIE_NAME}=([^;]+)`)) + return match ? match[2] : null +} + +function generateCsrfToken(): string { + const array = new Uint8Array(32) + crypto.getRandomValues(array) + return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('') +} + +function ensureCsrfToken(): string { + let token = getCsrfToken() + if (!token) { + token = generateCsrfToken() + // Set cookie with SameSite=Strict for CSRF protection + document.cookie = `${CSRF_COOKIE_NAME}=${token}; path=/; SameSite=Strict; Secure` + } + return token +} const getTelegramInitData = (): string | null => { if (typeof window === 'undefined') return null const initData = window.Telegram?.WebApp?.initData if (initData) { - try { - localStorage.setItem(TELEGRAM_INIT_STORAGE_KEY, initData) - } catch { - /* ignore storage errors */ - } + tokenStorage.setTelegramInitData(initData) return initData } - try { - return localStorage.getItem(TELEGRAM_INIT_STORAGE_KEY) - } catch { - return null - } + return tokenStorage.getTelegramInitData() } export const apiClient = axios.create({ @@ -31,9 +51,24 @@ export const apiClient = axios.create({ }, }) -// Request interceptor - add auth token -apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => { - const token = localStorage.getItem('access_token') +// Request interceptor - add auth token with expiration check +apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => { + let token = tokenStorage.getAccessToken() + + // Проверяем срок действия токена перед запросом + if (token && isTokenExpired(token)) { + // Используем централизованный менеджер для refresh + const newToken = await tokenRefreshManager.refreshAccessToken() + if (newToken) { + token = newToken + } else { + // Refresh не удался - редирект на логин + tokenStorage.clearTokens() + safeRedirectToLogin() + return config + } + } + if (token && config.headers) { config.headers.Authorization = `Bearer ${token}` } @@ -42,41 +77,36 @@ apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => { if (telegramInitData && config.headers) { config.headers['X-Telegram-Init-Data'] = telegramInitData } + + // Add CSRF token for state-changing methods + const method = config.method?.toUpperCase() + if (method && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && config.headers) { + config.headers[CSRF_HEADER_NAME] = ensureCsrfToken() + } + return config }) -// Response interceptor - handle 401, refresh token +// Response interceptor - handle 401 as fallback apiClient.interceptors.response.use( (response) => response, async (error: AxiosError) => { const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean } + // Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала) if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true - const refreshToken = localStorage.getItem('refresh_token') - if (refreshToken) { - try { - const response = await axios.post(`${API_BASE_URL}/cabinet/auth/refresh`, { - refresh_token: refreshToken, - }) - - const { access_token } = response.data - localStorage.setItem('access_token', access_token) - - if (originalRequest.headers) { - originalRequest.headers.Authorization = `Bearer ${access_token}` - } - - return apiClient(originalRequest) - } catch (refreshError) { - // Refresh failed, clear tokens and redirect to login - localStorage.removeItem('access_token') - localStorage.removeItem('refresh_token') - localStorage.removeItem('user') - window.location.href = '/login' - return Promise.reject(refreshError) + const newToken = await tokenRefreshManager.refreshAccessToken() + if (newToken) { + if (originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${newToken}` } + return apiClient(originalRequest) + } else { + // Refresh не удался + tokenStorage.clearTokens() + safeRedirectToLogin() } } @@ -85,4 +115,3 @@ apiClient.interceptors.response.use( ) export default apiClient - diff --git a/src/api/currency.ts b/src/api/currency.ts index 3288dcf..e5aa9f3 100644 --- a/src/api/currency.ts +++ b/src/api/currency.ts @@ -1,6 +1,8 @@ // Currency exchange rate API // Uses free exchangerate.host API +import axios from 'axios' + interface ExchangeRates { USD: number CNY: number @@ -33,39 +35,38 @@ export const currencyApi = { try { // Try primary API (exchangerate.host) - get RUB based rates - const response = await fetch( - 'https://api.exchangerate.host/latest?base=RUB&symbols=USD,CNY,IRR' - ) + const response = await axios.get<{ + success?: boolean + rates?: { USD?: number; CNY?: number; IRR?: number } + }>('https://api.exchangerate.host/latest', { + params: { base: 'RUB', symbols: 'USD,CNY,IRR' }, + }) - if (response.ok) { - const data = await response.json() - if (data.success && data.rates) { - // API returns how much of each currency equals 1 RUB - // We need the inverse: how many RUB equals 1 of each currency - const rates: ExchangeRates = { - USD: data.rates.USD ? 1 / data.rates.USD : FALLBACK_RATES.USD, - CNY: data.rates.CNY ? 1 / data.rates.CNY : FALLBACK_RATES.CNY, - IRR: data.rates.IRR ? 1 / data.rates.IRR : FALLBACK_RATES.IRR, - } - cachedRates = { rates, timestamp: Date.now() } - return rates + if (response.data.success && response.data.rates) { + // API returns how much of each currency equals 1 RUB + // We need the inverse: how many RUB equals 1 of each currency + const rates: ExchangeRates = { + USD: response.data.rates.USD ? 1 / response.data.rates.USD : FALLBACK_RATES.USD, + CNY: response.data.rates.CNY ? 1 / response.data.rates.CNY : FALLBACK_RATES.CNY, + IRR: response.data.rates.IRR ? 1 / response.data.rates.IRR : FALLBACK_RATES.IRR, } + cachedRates = { rates, timestamp: Date.now() } + return rates } // Try backup API (open.er-api.com) - const backupResponse = await fetch('https://open.er-api.com/v6/latest/RUB') + const backupResponse = await axios.get<{ + rates?: { USD?: number; CNY?: number; IRR?: number } + }>('https://open.er-api.com/v6/latest/RUB') - if (backupResponse.ok) { - const backupData = await backupResponse.json() - if (backupData.rates) { - const rates: ExchangeRates = { - USD: backupData.rates.USD ? 1 / backupData.rates.USD : FALLBACK_RATES.USD, - CNY: backupData.rates.CNY ? 1 / backupData.rates.CNY : FALLBACK_RATES.CNY, - IRR: backupData.rates.IRR ? 1 / backupData.rates.IRR : FALLBACK_RATES.IRR, - } - cachedRates = { rates, timestamp: Date.now() } - return rates + if (backupResponse.data.rates) { + const rates: ExchangeRates = { + USD: backupResponse.data.rates.USD ? 1 / backupResponse.data.rates.USD : FALLBACK_RATES.USD, + CNY: backupResponse.data.rates.CNY ? 1 / backupResponse.data.rates.CNY : FALLBACK_RATES.CNY, + IRR: backupResponse.data.rates.IRR ? 1 / backupResponse.data.rates.IRR : FALLBACK_RATES.IRR, } + cachedRates = { rates, timestamp: Date.now() } + return rates } // Return fallback rates if both APIs fail diff --git a/src/api/miniapp.ts b/src/api/miniapp.ts index 3fc2157..0801c30 100644 --- a/src/api/miniapp.ts +++ b/src/api/miniapp.ts @@ -1,4 +1,6 @@ -export interface MiniappCreatePaymentPayload { +import axios, { AxiosError } from 'axios' + +export interface MiniappCreatePaymentPayload { initData: string method: string amountKopeks?: number | null @@ -16,21 +18,26 @@ export const miniappApi = { createPayment: async ( payload: MiniappCreatePaymentPayload ): Promise => { - const res = await fetch('/miniapp/payments/create', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - initData: payload.initData || '', - method: payload.method, - amountKopeks: payload.amountKopeks ?? null, - option: payload.option ?? null, - }), - }) - const data = await res.json().catch(() => ({})) - if (!res.ok) { - const message = (data && (data.detail || data.message)) || 'Failed to create payment' - throw new Error(String(message)) + try { + const response = await axios.post( + '/miniapp/payments/create', + { + initData: payload.initData || '', + method: payload.method, + amountKopeks: payload.amountKopeks ?? null, + option: payload.option ?? null, + }, + { + headers: { 'Content-Type': 'application/json' }, + } + ) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ detail?: string; message?: string }> + const message = axiosError.response?.data?.detail + || axiosError.response?.data?.message + || 'Failed to create payment' + throw new Error(message) } - return data as MiniappCreatePaymentResponse }, } diff --git a/src/api/promoOffers.ts b/src/api/promoOffers.ts new file mode 100644 index 0000000..549325c --- /dev/null +++ b/src/api/promoOffers.ts @@ -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 + 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 + 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 + 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 => { + const response = await apiClient.get('/cabinet/admin/promo-offers', { params }) + return response.data + }, + + // Broadcast offer to multiple users + broadcastOffer: async (data: PromoOfferBroadcastRequest): Promise => { + 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 => { + const response = await apiClient.get('/cabinet/admin/promo-offers/logs', { params }) + return response.data + }, + + // Get all templates + getTemplates: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/promo-offers/templates') + return response.data + }, + + // Get single template + getTemplate: async (id: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/promo-offers/templates/${id}`) + return response.data + }, + + // Update template + updateTemplate: async (id: number, data: PromoOfferTemplateUpdateRequest): Promise => { + const response = await apiClient.patch(`/cabinet/admin/promo-offers/templates/${id}`, data) + return response.data + }, +} diff --git a/src/components/PromoDiscountBadge.tsx b/src/components/PromoDiscountBadge.tsx new file mode 100644 index 0000000..8f4f845 --- /dev/null +++ b/src/components/PromoDiscountBadge.tsx @@ -0,0 +1,153 @@ +import { useState, useRef, useEffect } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { promoApi } from '../api/promo' + +const SparklesIcon = () => ( + + + +) + +const ClockIcon = () => ( + + + +) + +const formatTimeLeft = (expiresAt: string, t: (key: string) => 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}${t('promo.time.days')}` + } + if (hours > 0) { + return `${hours}${t('promo.time.hours')} ${minutes}${t('promo.time.minutes')}` + } + return `${minutes}${t('promo.time.minutes')}` +} + +export default function PromoDiscountBadge() { + const { t } = useTranslation() + const navigate = useNavigate() + const [isOpen, setIsOpen] = useState(false) + const dropdownRef = useRef(null) + + const { data: activeDiscount } = useQuery({ + queryKey: ['active-discount'], + queryFn: promoApi.getActiveDiscount, + staleTime: 30000, + refetchInterval: 60000, // Refresh every minute + }) + + // Close dropdown on click outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + // Don't render if no active discount + if (!activeDiscount || !activeDiscount.is_active || !activeDiscount.discount_percent) { + return null + } + + const timeLeft = activeDiscount.expires_at ? formatTimeLeft(activeDiscount.expires_at, t) : null + + const handleClick = () => { + setIsOpen(!isOpen) + } + + const handleGoToSubscription = () => { + setIsOpen(false) + navigate('/subscription') + } + + return ( +
+ {/* Badge button */} + + + {/* Dropdown - mobile: fixed centered, desktop: absolute right */} + {isOpen && ( + <> + {/* Mobile backdrop */} +
setIsOpen(false)} + /> + +
+ {/* Header */} +
+
+
+ +
+
+
+ {t('promo.discountActive')} +
+
+ -{activeDiscount.discount_percent}% +
+
+
+
+ + {/* Content */} +
+

+ {t('promo.discountDescription')} +

+ + {/* Time remaining */} + {timeLeft && ( +
+ + {t('promo.expiresIn')}: {timeLeft} +
+ )} + + {/* CTA Button */} + +
+
+ + )} +
+ ) +} diff --git a/src/components/PromoOffersSection.tsx b/src/components/PromoOffersSection.tsx new file mode 100644 index 0000000..d125985 --- /dev/null +++ b/src/components/PromoOffersSection.tsx @@ -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 = () => ( + + + +) + +const ClockIcon = () => ( + + + +) + +const SparklesIcon = () => ( + + + +) + +const CheckIcon = () => ( + + + +) + +const ServerIcon = () => ( + + + +) + +// 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 + return +} + +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(null) + const [successMessage, setSuccessMessage] = useState(null) + const [errorMessage, setErrorMessage] = useState(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 ( +
+ {/* Active Discount Banner */} + {activeDiscount && activeDiscount.is_active && activeDiscount.discount_percent > 0 && ( +
+
+
+ +
+
+
+

+ Скидка {activeDiscount.discount_percent}% активна +

+ + Действует + +
+
+ {activeDiscount.expires_at && ( +
+ + Истекает: {formatTimeLeft(activeDiscount.expires_at)} +
+ )} +
+
+
+
+ )} + + {/* Success/Error Messages */} + {successMessage && ( +
+ + {successMessage} +
+ )} + + {errorMessage && ( +
+ {errorMessage} +
+ )} + + {/* Available Offers */} + {availableOffers.length > 0 && ( +
+ {availableOffers.map((offer) => ( +
+
+
+ {getOfferIcon(offer.effect_type)} +
+
+
+

+ {getOfferTitle(offer)} +

+ {offer.effect_type === 'test_access' && ( + + Тест + + )} +
+

+ {getOfferDescription(offer)} +

+
+
+ + Осталось: {formatTimeLeft(offer.expires_at)} +
+ +
+
+
+
+ ))} +
+ )} + + {/* Loading State */} + {offersLoading && ( +
+
+
+
+
+
+
+
+
+ )} +
+ ) +} diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index d4716d0..3b12f7a 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -19,7 +19,10 @@ const buildCryptoBotDeepLink = (url: string): string | null => { return `tg://resolve?domain=CryptoBot${parsed.search || ''}` } return null - } catch { return null } + } catch (e) { + console.warn('[TopUpModal] Failed to build CryptoBot deep link:', e) + return null + } } const openPaymentLink = (url: string, reservedWindow?: Window | null) => { @@ -28,7 +31,7 @@ const openPaymentLink = (url: string, reservedWindow?: Window | null) => { // If inside Telegram Mini App, let Telegram handle t.me links if (isTelegramPaymentLink(url) && webApp?.openTelegramLink) { - try { webApp.openTelegramLink(url); return } catch { /* ignore */ } + try { webApp.openTelegramLink(url); return } catch (e) { console.warn('[TopUpModal] openTelegramLink failed:', e) } } // Prefer Telegram deep link specifically for CryptoBot invoices, but only when @@ -37,7 +40,7 @@ const openPaymentLink = (url: string, reservedWindow?: Window | null) => { const target = cb && !isTelegramPaymentLink(url) ? cb : url if (reservedWindow && !reservedWindow.closed) { - try { reservedWindow.location.href = target; reservedWindow.focus?.() } catch { /* ignore */ } + try { reservedWindow.location.href = target; reservedWindow.focus?.() } catch (e) { console.warn('[TopUpModal] Failed to use reserved window:', e) } return } @@ -72,28 +75,20 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) { const starsPaymentMutation = useMutation({ mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks), onSuccess: (data) => { - console.log('[Stars] API response:', data) - console.log('[Stars] invoice_url:', data.invoice_url) const webApp = window.Telegram?.WebApp - console.log('[Stars] webApp:', webApp) - console.log('[Stars] openInvoice available:', !!webApp?.openInvoice) if (!data.invoice_url) { - console.error('[Stars] No invoice_url in response!') setError('Сервер не вернул ссылку на оплату') return } if (!webApp?.openInvoice) { - console.error('[Stars] openInvoice not available - not in Telegram Mini App?') setError('Оплата Stars доступна только в Telegram Mini App') return } - console.log('[Stars] Calling openInvoice with:', data.invoice_url) try { webApp.openInvoice(data.invoice_url, (status) => { - console.log('[Stars] Invoice callback status:', status) if (status === 'paid') { setError(null) onClose() @@ -104,12 +99,10 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) { } }) } catch (e) { - console.error('[Stars] openInvoice error:', e) setError('Ошибка открытия окна оплаты: ' + String(e)) } }, onError: (error: unknown) => { - console.error('[Stars] API error:', error) const axiosError = error as { response?: { data?: { detail?: string }, status?: number } } const detail = axiosError?.response?.data?.detail const status = axiosError?.response?.status @@ -136,7 +129,7 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) { onClose() }, onError: (error: unknown) => { - try { if (popupRef.current && !popupRef.current.closed) popupRef.current.close() } catch { /* ignore */ } + try { if (popupRef.current && !popupRef.current.closed) popupRef.current.close() } catch (e) { console.warn('[TopUpModal] Failed to close popup:', e) } popupRef.current = null const detail = (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '' if (detail.includes('not yet implemented')) setError(t('balance.useBot')) diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index df8a1eb..c1d2d98 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from 'react' import { useQuery } from '@tanstack/react-query' import { useAuthStore } from '../../store/auth' import LanguageSwitcher from '../LanguageSwitcher' +import PromoDiscountBadge from '../PromoDiscountBadge' import { contestsApi } from '../../api/contests' import { pollsApi } from '../../api/polls' import { brandingApi } from '../../api/branding' @@ -258,7 +259,7 @@ export default function Layout({ children }: LayoutProps) {
{/* Header */}
-
+
{/* Logo */} @@ -335,6 +336,7 @@ export default function Layout({ children }: LayoutProps) { )} + {/* Profile - Desktop */} diff --git a/src/locales/en.json b/src/locales/en.json index 20ec64e..604af76 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -307,6 +307,12 @@ "name": "FreeKassa", "description": "Pay via FreeKassa" } + }, + "pendingPayments": { + "title": "Pending Payments", + "pay": "Pay", + "checkStatus": "Check Status", + "checking": "Checking..." } }, "referral": { @@ -443,7 +449,10 @@ "tariffs": "Tariffs", "servers": "Servers", "banSystem": "Ban Monitoring", - "broadcasts": "Broadcasts" + "broadcasts": "Broadcasts", + "users": "Users", + "payments": "Payments", + "remnawave": "RemnaWave" }, "panel": { "title": "Admin Panel", @@ -456,7 +465,85 @@ "tariffsDesc": "Manage tariff plans", "serversDesc": "Configure VPN servers", "banSystemDesc": "Ban monitoring and violations", - "broadcastsDesc": "Mass messaging to users" + "broadcastsDesc": "Mass messaging to users", + "usersDesc": "Manage bot users", + "paymentsDesc": "Payment verification", + "remnawaveDesc": "Panel management and statistics" + }, + "payments": { + "title": "Payment Verification", + "description": "Pending payments in the last 24 hours", + "totalPending": "Total Pending", + "filterByMethod": "Filter by method", + "noPayments": "No pending payments", + "paid": "Paid", + "user": "User", + "openLink": "Open Link", + "checkStatus": "Check Status", + "checking": "Checking..." + }, + "remnawave": { + "title": "RemnaWave", + "subtitle": "Panel management and statistics", + "connected": "Connected", + "disconnected": "Not configured", + "noData": "Failed to load data", + "tabs": { + "overview": "Overview", + "nodes": "Nodes", + "squads": "Squads", + "sync": "Sync" + }, + "overview": { + "system": "System", + "usersOnline": "Online", + "totalUsers": "Total Users", + "nodesOnline": "Nodes Online", + "connections": "Connections", + "bandwidth": "Realtime Bandwidth", + "download": "Download", + "upload": "Upload", + "total": "Total", + "server": "Server", + "cpu": "CPU Cores", + "memory": "Memory", + "uptime": "Uptime", + "traffic": "Traffic Statistics", + "usersByStatus": "Users by Status" + }, + "nodes": { + "online": "Online", + "offline": "Offline", + "disabled": "Disabled", + "noNodes": "No nodes found", + "restart": "Restart", + "enable": "Enable", + "disable": "Disable", + "restartAll": "Restart All", + "confirmRestartAll": "Are you sure you want to restart all nodes?" + }, + "squads": { + "synced": "Synced", + "notSynced": "Not synced", + "noSquads": "No squads found", + "syncServers": "Sync Servers" + }, + "sync": { + "autoSync": "Auto Sync", + "runNow": "Run Now", + "manual": "Manual Sync", + "fromPanel": "Sync from Panel", + "fromPanelDesc": "Import users from RemnaWave panel to bot database", + "toPanel": "Sync to Panel", + "toPanelDesc": "Export users from bot database to RemnaWave panel", + "subscriptions": "Subscriptions", + "validate": "Validate", + "validateDesc": "Check and fix subscription inconsistencies", + "cleanup": "Cleanup", + "cleanupDesc": "Remove orphaned subscriptions without users", + "statuses": "Sync Statuses", + "statusesDesc": "Synchronize subscription statuses with panel" + } }, "wheel": { "title": "Fortune Wheel Settings", @@ -1179,5 +1266,19 @@ "description": "Use these buttons for quick access to main features: top up balance, extend subscription, and invite friends." } } + }, + "promo": { + "activeDiscount": "Active discount", + "discountActive": "Discount active!", + "discountDescription": "Your personal discount is applied to subscription purchases", + "expiresIn": "Expires in", + "source": "Source", + "useNow": "Use discount now", + "discountApplied": "Discount applied", + "time": { + "days": "d", + "hours": "h", + "minutes": "m" + } } } diff --git a/src/locales/fa.json b/src/locales/fa.json index d3777eb..d0b5598 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -762,5 +762,19 @@ "description": "از این دکمه‌ها برای دسترسی سریع به امکانات اصلی استفاده کنید: شارژ موجودی، تمدید اشتراک و دعوت دوستان." } } + }, + "promo": { + "activeDiscount": "تخفیف فعال", + "discountActive": "تخفیف فعال است!", + "discountDescription": "تخفیف شخصی شما برای خرید اشتراک اعمال می‌شود", + "expiresIn": "منقضی می‌شود در", + "source": "منبع", + "useNow": "همین الان استفاده کن", + "discountApplied": "تخفیف اعمال شد", + "time": { + "days": "روز", + "hours": "ساعت", + "minutes": "دقیقه" + } } } \ No newline at end of file diff --git a/src/locales/ru.json b/src/locales/ru.json index 8a61240..d9cfed8 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1,4 +1,4 @@ -{ +{ "common": { "loading": "Загрузка...", "error": "Ошибка", @@ -307,6 +307,12 @@ "name": "FreeKassa", "description": "Оплата через FreeKassa" } + }, + "pendingPayments": { + "title": "Ожидающие платежи", + "pay": "Оплатить", + "checkStatus": "Проверить статус", + "checking": "Проверка..." } }, "referral": { @@ -444,7 +450,9 @@ "servers": "Серверы", "banSystem": "Мониторинг банов", "broadcasts": "Рассылки", - "users": "Пользователи" + "users": "Пользователи", + "payments": "Платежи", + "remnawave": "RemnaWave" }, "panel": { "title": "Панель администратора", @@ -458,7 +466,84 @@ "serversDesc": "Настройка VPN серверов", "banSystemDesc": "Мониторинг банов и нарушений", "broadcastsDesc": "Массовая отправка сообщений", - "usersDesc": "Управление пользователями бота" + "usersDesc": "Управление пользователями бота", + "paymentsDesc": "Проверка платежей", + "remnawaveDesc": "Управление панелью и статистика" + }, + "payments": { + "title": "Проверка платежей", + "description": "Ожидающие платежи за последние 24 часа", + "totalPending": "Всего ожидает", + "filterByMethod": "Фильтр по методу", + "noPayments": "Нет ожидающих платежей", + "paid": "Оплачено", + "user": "Пользователь", + "openLink": "Открыть ссылку", + "checkStatus": "Проверить статус", + "checking": "Проверка..." + }, + "remnawave": { + "title": "RemnaWave", + "subtitle": "Управление панелью и статистика", + "connected": "Подключено", + "disconnected": "Не настроено", + "noData": "Не удалось загрузить данные", + "tabs": { + "overview": "Обзор", + "nodes": "Ноды", + "squads": "Сквады", + "sync": "Синхронизация" + }, + "overview": { + "system": "Система", + "usersOnline": "Онлайн", + "totalUsers": "Всего пользователей", + "nodesOnline": "Нод онлайн", + "connections": "Подключений", + "bandwidth": "Пропускная способность", + "download": "Загрузка", + "upload": "Отдача", + "total": "Всего", + "server": "Сервер", + "cpu": "CPU", + "memory": "Память", + "uptime": "Аптайм", + "traffic": "Статистика трафика", + "usersByStatus": "Пользователи по статусу" + }, + "nodes": { + "online": "Онлайн", + "offline": "Офлайн", + "disabled": "Отключена", + "noNodes": "Ноды не найдены", + "restart": "Перезапустить", + "enable": "Включить", + "disable": "Отключить", + "restartAll": "Перезапустить все", + "confirmRestartAll": "Вы уверены, что хотите перезапустить все ноды?" + }, + "squads": { + "synced": "Синхронизирован", + "notSynced": "Не синхронизирован", + "noSquads": "Сквады не найдены", + "syncServers": "Синхронизировать серверы" + }, + "sync": { + "autoSync": "Автосинхронизация", + "runNow": "Запустить сейчас", + "manual": "Ручная синхронизация", + "fromPanel": "Синхронизация из панели", + "fromPanelDesc": "Импорт пользователей из RemnaWave панели в базу бота", + "toPanel": "Синхронизация в панель", + "toPanelDesc": "Экспорт пользователей из базы бота в RemnaWave панель", + "subscriptions": "Подписки", + "validate": "Валидация", + "validateDesc": "Проверка и исправление несоответствий подписок", + "cleanup": "Очистка", + "cleanupDesc": "Удаление orphaned подписок без пользователей", + "statuses": "Синхронизация статусов", + "statusesDesc": "Синхронизация статусов подписок с панелью" + } }, "wheel": { "title": "Настройки колеса удачи", @@ -1339,5 +1424,19 @@ "description": "Используйте эти кнопки для быстрого доступа к основным функциям: пополнению баланса, продлению подписки и приглашению друзей." } } + }, + "promo": { + "activeDiscount": "Активная скидка", + "discountActive": "Скидка активна!", + "discountDescription": "Ваша персональная скидка применяется к покупке подписки", + "expiresIn": "Истекает через", + "source": "Источник", + "useNow": "Использовать сейчас", + "discountApplied": "Скидка применена", + "time": { + "days": "д", + "hours": "ч", + "minutes": "м" + } } } diff --git a/src/locales/zh.json b/src/locales/zh.json index b25e45d..bb0e0d2 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -762,5 +762,19 @@ "description": "使用这些按钮快速访问主要功能:充值余额、延长订阅和邀请好友。" } } + }, + "promo": { + "activeDiscount": "活动折扣", + "discountActive": "折扣已激活!", + "discountDescription": "您的个人折扣适用于订阅购买", + "expiresIn": "到期时间", + "source": "来源", + "useNow": "立即使用", + "discountApplied": "折扣已应用", + "time": { + "days": "天", + "hours": "时", + "minutes": "分" + } } } \ No newline at end of file diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 225d49c..ab10f97 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -81,6 +81,24 @@ const UsersIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( ) +const PaymentsIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( + + + +) + +const PromoOffersIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( + + + +) + +const RemnawaveIcon = ({ className = "w-8 h-8" }: { className?: string }) => ( + + + +) + const ChevronRightIcon = () => ( @@ -230,6 +248,16 @@ export default function AdminPanel() { bgColor: 'bg-violet-500/20', textColor: 'text-violet-400' }, + { + to: '/admin/promo-offers', + icon: , + mobileIcon: , + 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: , @@ -260,6 +288,26 @@ export default function AdminPanel() { bgColor: 'bg-red-500/20', textColor: 'text-red-400' }, + { + to: '/admin/payments', + icon: , + mobileIcon: , + 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: , + mobileIcon: , + title: t('admin.nav.remnawave', 'RemnaWave'), + description: t('admin.panel.remnawaveDesc', 'Управление панелью и статистика'), + color: 'purple', + bgColor: 'bg-purple-500/20', + textColor: 'text-purple-400' + }, ] return ( diff --git a/src/pages/AdminPayments.tsx b/src/pages/AdminPayments.tsx new file mode 100644 index 0000000..95aad68 --- /dev/null +++ b/src/pages/AdminPayments.tsx @@ -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('') + const [checkingPaymentId, setCheckingPaymentId] = useState(null) + + // Fetch payments + const { data: payments, isLoading, refetch } = useQuery>({ + 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 ( +
+ {/* Header */} +
+
+ + + + + +
+

{t('admin.payments.title', 'Проверка платежей')}

+

{t('admin.payments.description', 'Ожидающие платежи за последние 24 часа')}

+
+
+ +
+ + {/* Stats cards */} + {stats && ( +
+
+
{stats.total_pending}
+
{t('admin.payments.totalPending', 'Всего ожидает')}
+
+ {Object.entries(stats.by_method).map(([method, count]) => ( +
setMethodFilter(methodFilter === method ? '' : method)} + > +
{count}
+
{method}
+
+ ))} +
+ )} + + {/* Filter */} + {methodOptions.length > 0 && ( +
+ {t('admin.payments.filterByMethod', 'Фильтр по методу')}: + + {methodOptions.map((method) => ( + + ))} +
+ )} + + {/* Payments list */} +
+ {isLoading ? ( +
+
+
+ ) : payments?.items && payments.items.length > 0 ? ( +
+ {payments.items.map((payment) => { + const paymentKey = `${payment.method}_${payment.id}` + const isChecking = checkingPaymentId === paymentKey + + return ( +
+
+
+
+ {payment.method_display} + + {payment.status_emoji} {payment.status_text} + + {payment.is_paid && ( + + {t('admin.payments.paid', 'Оплачено')} + + )} +
+
+ {formatAmount(payment.amount_rubles)} {currencySymbol} +
+
+ ID: {payment.identifier} +
+
+ {new Date(payment.created_at).toLocaleString()} +
+ {/* User info */} + {(payment.user_username || payment.user_telegram_id) && ( +
+ {t('admin.payments.user', 'Пользователь')}:{' '} + {payment.user_username ? ( + @{payment.user_username} + ) : ( + ID: {payment.user_telegram_id} + )} +
+ )} +
+
+ {payment.payment_url && ( + + {t('admin.payments.openLink', 'Открыть ссылку')} + + )} + {payment.is_checkable && ( + + )} +
+
+ {/* Show result after check */} + {checkPaymentMutation.isSuccess && checkPaymentMutation.variables?.paymentId === payment.id && ( +
+ {checkPaymentMutation.data?.message} +
+ )} +
+ ) + })} +
+ ) : ( +
+
+ + + +
+
{t('admin.payments.noPayments', 'Нет ожидающих платежей')}
+
+ )} + + {/* Pagination */} + {payments && payments.pages > 1 && ( +
+ +
+ {t('balance.page', '{current} / {total}', { current: payments.page, total: payments.pages })} +
+ +
+ )} +
+
+ ) +} diff --git a/src/pages/AdminPromoOffers.tsx b/src/pages/AdminPromoOffers.tsx new file mode 100644 index 0000000..3e265b4 --- /dev/null +++ b/src/pages/AdminPromoOffers.tsx @@ -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 = () => ( + + + +) + +const EditIcon = () => ( + + + +) + +const XIcon = () => ( + + + +) + +const SendIcon = () => ( + + + +) + +const ClockIcon = () => ( + + + +) + +const UserIcon = () => ( + + + +) + +const CheckIcon = () => ( + + + +) + +const UsersIcon = () => ( + + + +) + +// 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 = { + created: 'Создано', + claimed: 'Активировано', + consumed: 'Использовано', + disabled: 'Деактивировано', + } + return labels[action] || action +} + +const getActionColor = (action: string): string => { + const colors: Record = { + 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 ( +
+
+ {/* Header */} +
+
+ {getOfferTypeIcon(template.offer_type)} +

+ Редактирование шаблона +

+
+ +
+ + {/* Content */} +
+
+ + 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" + /> +
+ +
+ +