mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
Merge branch 'main' from bedolaga
This commit is contained in:
@@ -20,5 +20,5 @@ VITE_APP_LOGO=V
|
|||||||
# ===== RUNTIME VARIABLES =====
|
# ===== RUNTIME VARIABLES =====
|
||||||
|
|
||||||
# Port to expose the frontend on host machine
|
# Port to expose the frontend on host machine
|
||||||
# Default: 3000 (http://localhost:3000)
|
# Default: 3020 (http://localhost:3020)
|
||||||
CABINET_PORT=3000
|
CABINET_PORT=3020
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ FROM nginx:alpine
|
|||||||
|
|
||||||
# Copy built assets from builder stage
|
# Copy built assets from builder stage
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
37
README.md
37
README.md
@@ -1,6 +1,6 @@
|
|||||||
# Bedolaga Cabinet - Web Interface
|
# 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
|
VITE_APP_LOGO=V
|
||||||
|
|
||||||
# Порт для Docker контейнера
|
# Порт для Docker контейнера
|
||||||
CABINET_PORT=3000
|
CABINET_PORT=3020
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3. Запуск в Docker
|
#### 3. Запуск в Docker
|
||||||
@@ -101,6 +101,7 @@ CABINET_ALLOWED_ORIGINS=http://localhost:3000,https://cabinet.yourdomain.com
|
|||||||
## Настройка прокси для production
|
## Настройка прокси для production
|
||||||
|
|
||||||
Frontend - это статические файлы (HTML, JS, CSS). Для работы нужно:
|
Frontend - это статические файлы (HTML, JS, CSS). Для работы нужно:
|
||||||
|
|
||||||
1. Раздавать статику через веб-сервер
|
1. Раздавать статику через веб-сервер
|
||||||
2. Проксировать `/api/*` запросы на backend бота
|
2. Проксировать `/api/*` запросы на backend бота
|
||||||
|
|
||||||
@@ -153,8 +154,8 @@ services:
|
|||||||
- ./cabinet-dist:/srv/cabinet:ro
|
- ./cabinet-dist:/srv/cabinet:ro
|
||||||
- caddy_data:/data
|
- caddy_data:/data
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- '80:80'
|
||||||
- "443:443"
|
- '443:443'
|
||||||
networks:
|
networks:
|
||||||
- bot_network
|
- bot_network
|
||||||
```
|
```
|
||||||
@@ -164,6 +165,7 @@ services:
|
|||||||
Если хотите использовать готовый Docker контейнер с nginx внутри.
|
Если хотите использовать готовый Docker контейнер с nginx внутри.
|
||||||
|
|
||||||
**⚠️ Важно:**
|
**⚠️ Важно:**
|
||||||
|
|
||||||
- Порт `80` в примерах - это **внутренний порт контейнера** (nginx внутри), не хост-порт!
|
- Порт `80` в примерах - это **внутренний порт контейнера** (nginx внутри), не хост-порт!
|
||||||
- Контейнеры должны быть в одной Docker сети для связи друг с другом
|
- Контейнеры должны быть в одной Docker сети для связи друг с другом
|
||||||
- Имена контейнеров используются как DNS внутри Docker сети
|
- Имена контейнеров используются как DNS внутри Docker сети
|
||||||
@@ -194,11 +196,12 @@ services:
|
|||||||
|
|
||||||
networks:
|
networks:
|
||||||
bot_network:
|
bot_network:
|
||||||
external: true # Используем существующую сеть
|
external: true # Используем существующую сеть
|
||||||
name: remnawave-bedolaga-telegram-bot_bot_network # Пример для bedolaga bot
|
name: remnawave-bedolaga-telegram-bot_bot_network # Пример для bedolaga bot
|
||||||
```
|
```
|
||||||
|
|
||||||
**Важно:** Замените имя сети на вашу:
|
**Важно:** Замените имя сети на вашу:
|
||||||
|
|
||||||
- Если у вас bot + caddy: используйте сеть бота (обычно `<название_проекта>_bot_network`)
|
- Если у вас bot + caddy: используйте сеть бота (обычно `<название_проекта>_bot_network`)
|
||||||
- Если отдельный Caddy: узнайте через `docker network ls`
|
- Если отдельный Caddy: узнайте через `docker network ls`
|
||||||
- Если используете Traefik: обычно `traefik` или `web`
|
- Если используете Traefik: обычно `traefik` или `web`
|
||||||
@@ -212,6 +215,7 @@ docker compose up -d
|
|||||||
**Шаг 4:** Добавьте в конфигурацию Caddy/Nginx:
|
**Шаг 4:** Добавьте в конфигурацию Caddy/Nginx:
|
||||||
|
|
||||||
Caddy проксирует на контейнер:
|
Caddy проксирует на контейнер:
|
||||||
|
|
||||||
```caddyfile
|
```caddyfile
|
||||||
cabinet.yourdomain.com {
|
cabinet.yourdomain.com {
|
||||||
# API на backend
|
# API на backend
|
||||||
@@ -228,6 +232,7 @@ cabinet.yourdomain.com {
|
|||||||
```
|
```
|
||||||
|
|
||||||
Nginx (добавьте в существующий конфиг):
|
Nginx (добавьте в существующий конфиг):
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
@@ -269,6 +274,7 @@ docker exec <nginx_container> nginx -s reload
|
|||||||
#### B. Если Caddy/Nginx ещё НЕ запущен:
|
#### B. Если Caddy/Nginx ещё НЕ запущен:
|
||||||
|
|
||||||
docker-compose.yml:
|
docker-compose.yml:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
cabinet-frontend:
|
cabinet-frontend:
|
||||||
@@ -292,18 +298,18 @@ networks:
|
|||||||
|
|
||||||
### Build-time (используются при сборке)
|
### Build-time (используются при сборке)
|
||||||
|
|
||||||
| Переменная | Описание | По умолчанию |
|
| Переменная | Описание | По умолчанию |
|
||||||
|------------|----------|--------------|
|
| ---------------------------- | ---------------------------------- | ------------ |
|
||||||
| `VITE_API_URL` | Путь к API (`/api` или полный URL) | `/api` |
|
| `VITE_API_URL` | Путь к API (`/api` или полный URL) | `/api` |
|
||||||
| `VITE_TELEGRAM_BOT_USERNAME` | Username Telegram бота (без @) | - |
|
| `VITE_TELEGRAM_BOT_USERNAME` | Username Telegram бота (без @) | - |
|
||||||
| `VITE_APP_NAME` | Название приложения | `Cabinet` |
|
| `VITE_APP_NAME` | Название приложения | `Cabinet` |
|
||||||
| `VITE_APP_LOGO` | Логотип (короткий текст) | `V` |
|
| `VITE_APP_LOGO` | Логотип (короткий текст) | `V` |
|
||||||
|
|
||||||
### Runtime (только для Docker)
|
### Runtime (только для Docker)
|
||||||
|
|
||||||
| Переменная | Описание | По умолчанию |
|
| Переменная | Описание | По умолчанию |
|
||||||
|------------|----------|--------------|
|
| -------------- | --------------- | ------------ |
|
||||||
| `CABINET_PORT` | Порт контейнера | `3000` |
|
| `CABINET_PORT` | Порт контейнера | `3000` |
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
|
|
||||||
@@ -337,6 +343,7 @@ bedolaga-cabinet/
|
|||||||
### 502 Bad Gateway
|
### 502 Bad Gateway
|
||||||
|
|
||||||
Убедитесь что:
|
Убедитесь что:
|
||||||
|
|
||||||
1. Backend бот запущен и работает
|
1. Backend бот запущен и работает
|
||||||
2. Контейнеры находятся в одной Docker сети
|
2. Контейнеры находятся в одной Docker сети
|
||||||
3. Имя сервиса backend в прокси конфигурации правильное
|
3. Имя сервиса backend в прокси конфигурации правильное
|
||||||
|
|||||||
@@ -16,13 +16,21 @@ services:
|
|||||||
container_name: cabinet_frontend
|
container_name: cabinet_frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env # Загрузить переменные из .env файла
|
- .env # Загрузить переменные из .env файла
|
||||||
ports:
|
ports:
|
||||||
# Маппинг: <хост-порт>:<контейнер-порт>
|
# Маппинг: <хост-порт>:<контейнер-порт>
|
||||||
# Внутри контейнера nginx слушает на порту 80
|
# Внутри контейнера nginx слушает на порту 80
|
||||||
- "${CABINET_PORT:-3000}:80"
|
- '${CABINET_PORT:-3020}:80'
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
29
nginx.conf
Normal file
29
nginx.conf
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SPA fallback
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API проксировать, если нужно
|
||||||
|
location /api/ {
|
||||||
|
try_files $uri /index.html;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional: кэширование статических файлов
|
||||||
|
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg)$ {
|
||||||
|
expires 1y;
|
||||||
|
access_log off;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
}
|
||||||
28
package-lock.json
generated
28
package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@lottiefiles/dotlottie-react": "^0.8.0",
|
"@lottiefiles/dotlottie-react": "^0.8.0",
|
||||||
"@tanstack/react-query": "^5.8.0",
|
"@tanstack/react-query": "^5.8.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
"i18next": "^23.7.0",
|
"i18next": "^23.7.0",
|
||||||
"i18next-browser-languagedetector": "^7.2.0",
|
"i18next-browser-languagedetector": "^7.2.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"zustand": "^4.4.0"
|
"zustand": "^4.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/react": "^18.2.37",
|
"@types/react": "^18.2.37",
|
||||||
"@types/react-dom": "^18.2.15",
|
"@types/react-dom": "^18.2.15",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||||
@@ -1402,6 +1404,16 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -1452,6 +1464,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
|
||||||
@@ -2207,6 +2226,15 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
|||||||
@@ -12,17 +12,19 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lottiefiles/dotlottie-react": "^0.8.0",
|
"@lottiefiles/dotlottie-react": "^0.8.0",
|
||||||
|
"@tanstack/react-query": "^5.8.0",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
"i18next": "^23.7.0",
|
"i18next": "^23.7.0",
|
||||||
"i18next-browser-languagedetector": "^7.2.0",
|
"i18next-browser-languagedetector": "^7.2.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-i18next": "^13.5.0",
|
"react-i18next": "^13.5.0",
|
||||||
"react-router-dom": "^6.20.0",
|
"react-router-dom": "^6.20.0",
|
||||||
"zustand": "^4.4.0",
|
"zustand": "^4.4.0"
|
||||||
"@tanstack/react-query": "^5.8.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/react": "^18.2.37",
|
"@types/react": "^18.2.37",
|
||||||
"@types/react-dom": "^18.2.15",
|
"@types/react-dom": "^18.2.15",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||||
|
|||||||
27
src/App.tsx
27
src/App.tsx
@@ -30,6 +30,9 @@ import AdminBroadcasts from './pages/AdminBroadcasts'
|
|||||||
import AdminPromocodes from './pages/AdminPromocodes'
|
import AdminPromocodes from './pages/AdminPromocodes'
|
||||||
import AdminCampaigns from './pages/AdminCampaigns'
|
import AdminCampaigns from './pages/AdminCampaigns'
|
||||||
import AdminUsers from './pages/AdminUsers'
|
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 }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
const { isAuthenticated, isLoading } = useAuthStore()
|
const { isAuthenticated, isLoading } = useAuthStore()
|
||||||
@@ -262,6 +265,30 @@ function App() {
|
|||||||
</AdminRoute>
|
</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 */}
|
{/* Catch all */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
39
src/api/adminPayments.ts
Normal file
39
src/api/adminPayments.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
import type { PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types'
|
||||||
|
|
||||||
|
export interface PaymentsStats {
|
||||||
|
total_pending: number
|
||||||
|
by_method: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminPaymentsApi = {
|
||||||
|
// Get all pending payments (admin)
|
||||||
|
getPendingPayments: async (params?: {
|
||||||
|
page?: number
|
||||||
|
per_page?: number
|
||||||
|
method_filter?: string
|
||||||
|
}): Promise<PaginatedResponse<PendingPayment>> => {
|
||||||
|
const response = await apiClient.get<PaginatedResponse<PendingPayment>>('/cabinet/admin/payments', {
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get payments statistics
|
||||||
|
getStats: async (): Promise<PaymentsStats> => {
|
||||||
|
const response = await apiClient.get<PaymentsStats>('/cabinet/admin/payments/stats')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get specific payment details
|
||||||
|
getPayment: async (method: string, paymentId: number): Promise<PendingPayment> => {
|
||||||
|
const response = await apiClient.get<PendingPayment>(`/cabinet/admin/payments/${method}/${paymentId}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Manually check payment status
|
||||||
|
checkPaymentStatus: async (method: string, paymentId: number): Promise<ManualCheckResponse> => {
|
||||||
|
const response = await apiClient.post<ManualCheckResponse>(`/cabinet/admin/payments/${method}/${paymentId}/check`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
386
src/api/adminRemnawave.ts
Normal file
386
src/api/adminRemnawave.ts
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
import { apiClient } from './client'
|
||||||
|
|
||||||
|
// ============ Types ============
|
||||||
|
|
||||||
|
// Status & Connection
|
||||||
|
export interface ConnectionStatus {
|
||||||
|
status: string
|
||||||
|
message: string
|
||||||
|
api_url?: string
|
||||||
|
status_code?: number
|
||||||
|
system_info?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemnaWaveStatusResponse {
|
||||||
|
is_configured: boolean
|
||||||
|
configuration_error?: string
|
||||||
|
connection?: ConnectionStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// System Statistics
|
||||||
|
export interface SystemSummary {
|
||||||
|
users_online: number
|
||||||
|
total_users: number
|
||||||
|
active_connections: number
|
||||||
|
nodes_online: number
|
||||||
|
users_last_day: number
|
||||||
|
users_last_week: number
|
||||||
|
users_never_online: number
|
||||||
|
total_user_traffic: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerInfo {
|
||||||
|
cpu_cores: number
|
||||||
|
cpu_physical_cores: number
|
||||||
|
memory_total: number
|
||||||
|
memory_used: number
|
||||||
|
memory_free: number
|
||||||
|
memory_available: number
|
||||||
|
uptime_seconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Bandwidth {
|
||||||
|
realtime_download: number
|
||||||
|
realtime_upload: number
|
||||||
|
realtime_total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrafficPeriod {
|
||||||
|
current: number
|
||||||
|
previous: number
|
||||||
|
difference?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrafficPeriods {
|
||||||
|
last_2_days: TrafficPeriod
|
||||||
|
last_7_days: TrafficPeriod
|
||||||
|
last_30_days: TrafficPeriod
|
||||||
|
current_month: TrafficPeriod
|
||||||
|
current_year: TrafficPeriod
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemStatsResponse {
|
||||||
|
system: SystemSummary
|
||||||
|
users_by_status: Record<string, number>
|
||||||
|
server_info: ServerInfo
|
||||||
|
bandwidth: Bandwidth
|
||||||
|
traffic_periods: TrafficPeriods
|
||||||
|
nodes_realtime: Record<string, unknown>[]
|
||||||
|
nodes_weekly: Record<string, unknown>[]
|
||||||
|
last_updated?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nodes
|
||||||
|
export interface NodeInfo {
|
||||||
|
uuid: string
|
||||||
|
name: string
|
||||||
|
address: string
|
||||||
|
country_code?: string
|
||||||
|
is_connected: boolean
|
||||||
|
is_disabled: boolean
|
||||||
|
is_node_online: boolean
|
||||||
|
is_xray_running: boolean
|
||||||
|
users_online?: number
|
||||||
|
traffic_used_bytes?: number
|
||||||
|
traffic_limit_bytes?: number
|
||||||
|
last_status_change?: string
|
||||||
|
last_status_message?: string
|
||||||
|
xray_uptime?: string
|
||||||
|
is_traffic_tracking_active: boolean
|
||||||
|
traffic_reset_day?: number
|
||||||
|
notify_percent?: number
|
||||||
|
consumption_multiplier: number
|
||||||
|
cpu_count?: number
|
||||||
|
cpu_model?: string
|
||||||
|
total_ram?: string
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
provider_uuid?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodesListResponse {
|
||||||
|
items: NodeInfo[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodesOverview {
|
||||||
|
total: number
|
||||||
|
online: number
|
||||||
|
offline: number
|
||||||
|
disabled: number
|
||||||
|
total_users_online: number
|
||||||
|
nodes: NodeInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeStatisticsResponse {
|
||||||
|
node: NodeInfo
|
||||||
|
realtime?: Record<string, unknown>
|
||||||
|
usage_history: Record<string, unknown>[]
|
||||||
|
last_updated?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeActionResponse {
|
||||||
|
success: boolean
|
||||||
|
message?: string
|
||||||
|
is_disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Squads
|
||||||
|
export interface SquadWithLocalInfo {
|
||||||
|
uuid: string
|
||||||
|
name: string
|
||||||
|
members_count: number
|
||||||
|
inbounds_count: number
|
||||||
|
inbounds: Record<string, unknown>[]
|
||||||
|
local_id?: number
|
||||||
|
display_name?: string
|
||||||
|
country_code?: string
|
||||||
|
is_available?: boolean
|
||||||
|
is_trial_eligible?: boolean
|
||||||
|
price_kopeks?: number
|
||||||
|
max_users?: number
|
||||||
|
current_users?: number
|
||||||
|
is_synced: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SquadsListResponse {
|
||||||
|
items: SquadWithLocalInfo[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SquadDetailResponse extends SquadWithLocalInfo {
|
||||||
|
description?: string
|
||||||
|
sort_order?: number
|
||||||
|
active_subscriptions: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SquadOperationResponse {
|
||||||
|
success: boolean
|
||||||
|
message?: string
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration
|
||||||
|
export interface MigrationPreviewResponse {
|
||||||
|
squad_uuid: string
|
||||||
|
squad_name: string
|
||||||
|
current_users: number
|
||||||
|
max_users?: number
|
||||||
|
users_to_migrate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MigrationStats {
|
||||||
|
source_uuid: string
|
||||||
|
target_uuid: string
|
||||||
|
total: number
|
||||||
|
updated: number
|
||||||
|
panel_updated: number
|
||||||
|
panel_failed: number
|
||||||
|
source_removed: number
|
||||||
|
target_added: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MigrationResponse {
|
||||||
|
success: boolean
|
||||||
|
message?: string
|
||||||
|
error?: string
|
||||||
|
data?: MigrationStats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inbounds
|
||||||
|
export interface InboundsListResponse {
|
||||||
|
items: Record<string, unknown>[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto Sync
|
||||||
|
export interface AutoSyncStatus {
|
||||||
|
enabled: boolean
|
||||||
|
times: string[]
|
||||||
|
next_run?: string
|
||||||
|
is_running: boolean
|
||||||
|
last_run_started_at?: string
|
||||||
|
last_run_finished_at?: string
|
||||||
|
last_run_success?: boolean
|
||||||
|
last_run_reason?: string
|
||||||
|
last_run_error?: string
|
||||||
|
last_user_stats?: Record<string, unknown>
|
||||||
|
last_server_stats?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutoSyncRunResponse {
|
||||||
|
started: boolean
|
||||||
|
success?: boolean
|
||||||
|
error?: string
|
||||||
|
user_stats?: Record<string, unknown>
|
||||||
|
server_stats?: Record<string, unknown>
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
export interface SyncResponse {
|
||||||
|
success: boolean
|
||||||
|
message?: string
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ API ============
|
||||||
|
|
||||||
|
export const adminRemnawaveApi = {
|
||||||
|
// Status & Connection
|
||||||
|
getStatus: async (): Promise<RemnaWaveStatusResponse> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/remnawave/status')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// System Statistics
|
||||||
|
getSystemStats: async (): Promise<SystemStatsResponse> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/remnawave/system')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Nodes
|
||||||
|
getNodes: async (): Promise<NodesListResponse> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/remnawave/nodes')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getNodesOverview: async (): Promise<NodesOverview> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/remnawave/nodes/overview')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getNodesRealtime: async (): Promise<Record<string, unknown>[]> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/remnawave/nodes/realtime')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getNode: async (uuid: string): Promise<NodeInfo> => {
|
||||||
|
const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getNodeStatistics: async (uuid: string): Promise<NodeStatisticsResponse> => {
|
||||||
|
const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}/statistics`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
nodeAction: async (uuid: string, action: 'enable' | 'disable' | 'restart'): Promise<NodeActionResponse> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/admin/remnawave/nodes/${uuid}/action`, { action })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
restartAllNodes: async (): Promise<NodeActionResponse> => {
|
||||||
|
const response = await apiClient.post('/cabinet/admin/remnawave/nodes/restart-all')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Squads
|
||||||
|
getSquads: async (): Promise<SquadsListResponse> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/remnawave/squads')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getSquad: async (uuid: string): Promise<SquadDetailResponse> => {
|
||||||
|
const response = await apiClient.get(`/cabinet/admin/remnawave/squads/${uuid}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
createSquad: async (data: { name: string; inbound_uuids?: string[] }): Promise<SquadOperationResponse> => {
|
||||||
|
const response = await apiClient.post('/cabinet/admin/remnawave/squads', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSquad: async (uuid: string, data: { name?: string; inbound_uuids?: string[] }): Promise<SquadOperationResponse> => {
|
||||||
|
const response = await apiClient.patch(`/cabinet/admin/remnawave/squads/${uuid}`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteSquad: async (uuid: string): Promise<SquadOperationResponse> => {
|
||||||
|
const response = await apiClient.delete(`/cabinet/admin/remnawave/squads/${uuid}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
squadAction: async (uuid: string, data: {
|
||||||
|
action: 'add_all_users' | 'remove_all_users' | 'delete' | 'rename' | 'update_inbounds'
|
||||||
|
name?: string
|
||||||
|
inbound_uuids?: string[]
|
||||||
|
}): Promise<SquadOperationResponse> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/admin/remnawave/squads/${uuid}/action`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Migration
|
||||||
|
getMigrationPreview: async (uuid: string): Promise<MigrationPreviewResponse> => {
|
||||||
|
const response = await apiClient.get(`/cabinet/admin/remnawave/squads/${uuid}/migration-preview`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
migrateSquad: async (sourceUuid: string, targetUuid: string): Promise<MigrationResponse> => {
|
||||||
|
const response = await apiClient.post('/cabinet/admin/remnawave/squads/migrate', {
|
||||||
|
source_uuid: sourceUuid,
|
||||||
|
target_uuid: targetUuid,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Inbounds
|
||||||
|
getInbounds: async (): Promise<InboundsListResponse> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/remnawave/inbounds')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Auto Sync
|
||||||
|
getAutoSyncStatus: async (): Promise<AutoSyncStatus> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/remnawave/sync/auto/status')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleAutoSync: async (enabled: boolean): Promise<SyncResponse> => {
|
||||||
|
const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/toggle', { enabled })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
runAutoSync: async (): Promise<AutoSyncRunResponse> => {
|
||||||
|
const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/run')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Manual Sync
|
||||||
|
syncFromPanel: async (mode: 'all' | 'new_only' | 'update_only' = 'all'): Promise<SyncResponse> => {
|
||||||
|
const response = await apiClient.post('/cabinet/admin/remnawave/sync/from-panel', { mode })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
syncToPanel: async (): Promise<SyncResponse> => {
|
||||||
|
const response = await apiClient.post('/cabinet/admin/remnawave/sync/to-panel')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
syncServers: async (): Promise<SyncResponse> => {
|
||||||
|
const response = await apiClient.post('/cabinet/admin/remnawave/sync/servers')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
validateSubscriptions: async (): Promise<SyncResponse> => {
|
||||||
|
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/validate')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
cleanupSubscriptions: async (): Promise<SyncResponse> => {
|
||||||
|
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/cleanup')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
syncSubscriptionStatuses: async (): Promise<SyncResponse> => {
|
||||||
|
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/statuses')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
getSyncRecommendations: async (): Promise<SyncResponse> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/remnawave/sync/recommendations')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default adminRemnawaveApi
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client'
|
||||||
import type { Balance, Transaction, PaymentMethod, PaginatedResponse } from '../types'
|
import type { Balance, Transaction, PaymentMethod, PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types'
|
||||||
|
|
||||||
export const balanceApi = {
|
export const balanceApi = {
|
||||||
// Get current balance
|
// Get current balance
|
||||||
@@ -73,5 +73,28 @@ export const balanceApi = {
|
|||||||
})
|
})
|
||||||
return response.data
|
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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,47 @@
|
|||||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
|
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 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 => {
|
const getTelegramInitData = (): string | null => {
|
||||||
if (typeof window === 'undefined') return null
|
if (typeof window === 'undefined') return null
|
||||||
|
|
||||||
const initData = window.Telegram?.WebApp?.initData
|
const initData = window.Telegram?.WebApp?.initData
|
||||||
if (initData) {
|
if (initData) {
|
||||||
try {
|
tokenStorage.setTelegramInitData(initData)
|
||||||
localStorage.setItem(TELEGRAM_INIT_STORAGE_KEY, initData)
|
|
||||||
} catch {
|
|
||||||
/* ignore storage errors */
|
|
||||||
}
|
|
||||||
return initData
|
return initData
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return tokenStorage.getTelegramInitData()
|
||||||
return localStorage.getItem(TELEGRAM_INIT_STORAGE_KEY)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = axios.create({
|
export const apiClient = axios.create({
|
||||||
@@ -31,9 +51,24 @@ export const apiClient = axios.create({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Request interceptor - add auth token
|
// Request interceptor - add auth token with expiration check
|
||||||
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
|
||||||
const token = localStorage.getItem('access_token')
|
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) {
|
if (token && config.headers) {
|
||||||
config.headers.Authorization = `Bearer ${token}`
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
@@ -42,41 +77,36 @@ apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
|||||||
if (telegramInitData && config.headers) {
|
if (telegramInitData && config.headers) {
|
||||||
config.headers['X-Telegram-Init-Data'] = telegramInitData
|
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
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
// Response interceptor - handle 401, refresh token
|
// Response interceptor - handle 401 as fallback
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error: AxiosError) => {
|
async (error: AxiosError) => {
|
||||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||||
|
|
||||||
|
// Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
|
||||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
originalRequest._retry = true
|
originalRequest._retry = true
|
||||||
|
|
||||||
const refreshToken = localStorage.getItem('refresh_token')
|
const newToken = await tokenRefreshManager.refreshAccessToken()
|
||||||
if (refreshToken) {
|
if (newToken) {
|
||||||
try {
|
if (originalRequest.headers) {
|
||||||
const response = await axios.post(`${API_BASE_URL}/cabinet/auth/refresh`, {
|
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
return apiClient(originalRequest)
|
||||||
|
} else {
|
||||||
|
// Refresh не удался
|
||||||
|
tokenStorage.clearTokens()
|
||||||
|
safeRedirectToLogin()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,4 +115,3 @@ apiClient.interceptors.response.use(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export default apiClient
|
export default apiClient
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
// Currency exchange rate API
|
// Currency exchange rate API
|
||||||
// Uses free exchangerate.host API
|
// Uses free exchangerate.host API
|
||||||
|
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
interface ExchangeRates {
|
interface ExchangeRates {
|
||||||
USD: number
|
USD: number
|
||||||
CNY: number
|
CNY: number
|
||||||
@@ -33,39 +35,38 @@ export const currencyApi = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Try primary API (exchangerate.host) - get RUB based rates
|
// Try primary API (exchangerate.host) - get RUB based rates
|
||||||
const response = await fetch(
|
const response = await axios.get<{
|
||||||
'https://api.exchangerate.host/latest?base=RUB&symbols=USD,CNY,IRR'
|
success?: boolean
|
||||||
)
|
rates?: { USD?: number; CNY?: number; IRR?: number }
|
||||||
|
}>('https://api.exchangerate.host/latest', {
|
||||||
|
params: { base: 'RUB', symbols: 'USD,CNY,IRR' },
|
||||||
|
})
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.data.success && response.data.rates) {
|
||||||
const data = await response.json()
|
// API returns how much of each currency equals 1 RUB
|
||||||
if (data.success && data.rates) {
|
// We need the inverse: how many RUB equals 1 of each currency
|
||||||
// API returns how much of each currency equals 1 RUB
|
const rates: ExchangeRates = {
|
||||||
// We need the inverse: how many RUB equals 1 of each currency
|
USD: response.data.rates.USD ? 1 / response.data.rates.USD : FALLBACK_RATES.USD,
|
||||||
const rates: ExchangeRates = {
|
CNY: response.data.rates.CNY ? 1 / response.data.rates.CNY : FALLBACK_RATES.CNY,
|
||||||
USD: data.rates.USD ? 1 / data.rates.USD : FALLBACK_RATES.USD,
|
IRR: response.data.rates.IRR ? 1 / response.data.rates.IRR : FALLBACK_RATES.IRR,
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
cachedRates = { rates, timestamp: Date.now() }
|
||||||
|
return rates
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try backup API (open.er-api.com)
|
// 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) {
|
if (backupResponse.data.rates) {
|
||||||
const backupData = await backupResponse.json()
|
const rates: ExchangeRates = {
|
||||||
if (backupData.rates) {
|
USD: backupResponse.data.rates.USD ? 1 / backupResponse.data.rates.USD : FALLBACK_RATES.USD,
|
||||||
const rates: ExchangeRates = {
|
CNY: backupResponse.data.rates.CNY ? 1 / backupResponse.data.rates.CNY : FALLBACK_RATES.CNY,
|
||||||
USD: backupData.rates.USD ? 1 / backupData.rates.USD : FALLBACK_RATES.USD,
|
IRR: backupResponse.data.rates.IRR ? 1 / backupResponse.data.rates.IRR : FALLBACK_RATES.IRR,
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
cachedRates = { rates, timestamp: Date.now() }
|
||||||
|
return rates
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return fallback rates if both APIs fail
|
// Return fallback rates if both APIs fail
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export interface MiniappCreatePaymentPayload {
|
import axios, { AxiosError } from 'axios'
|
||||||
|
|
||||||
|
export interface MiniappCreatePaymentPayload {
|
||||||
initData: string
|
initData: string
|
||||||
method: string
|
method: string
|
||||||
amountKopeks?: number | null
|
amountKopeks?: number | null
|
||||||
@@ -16,21 +18,26 @@ export const miniappApi = {
|
|||||||
createPayment: async (
|
createPayment: async (
|
||||||
payload: MiniappCreatePaymentPayload
|
payload: MiniappCreatePaymentPayload
|
||||||
): Promise<MiniappCreatePaymentResponse> => {
|
): Promise<MiniappCreatePaymentResponse> => {
|
||||||
const res = await fetch('/miniapp/payments/create', {
|
try {
|
||||||
method: 'POST',
|
const response = await axios.post<MiniappCreatePaymentResponse>(
|
||||||
headers: { 'Content-Type': 'application/json' },
|
'/miniapp/payments/create',
|
||||||
body: JSON.stringify({
|
{
|
||||||
initData: payload.initData || '',
|
initData: payload.initData || '',
|
||||||
method: payload.method,
|
method: payload.method,
|
||||||
amountKopeks: payload.amountKopeks ?? null,
|
amountKopeks: payload.amountKopeks ?? null,
|
||||||
option: payload.option ?? null,
|
option: payload.option ?? null,
|
||||||
}),
|
},
|
||||||
})
|
{
|
||||||
const data = await res.json().catch(() => ({}))
|
headers: { 'Content-Type': 'application/json' },
|
||||||
if (!res.ok) {
|
}
|
||||||
const message = (data && (data.detail || data.message)) || 'Failed to create payment'
|
)
|
||||||
throw new Error(String(message))
|
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
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
227
src/api/promoOffers.ts
Normal file
227
src/api/promoOffers.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
|
||||||
|
// ============== Types ==============
|
||||||
|
|
||||||
|
export interface PromoOfferUserInfo {
|
||||||
|
id: number
|
||||||
|
telegram_id: number
|
||||||
|
username: string | null
|
||||||
|
first_name: string | null
|
||||||
|
last_name: string | null
|
||||||
|
full_name: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromoOfferSubscriptionInfo {
|
||||||
|
id: number
|
||||||
|
status: string
|
||||||
|
is_trial: boolean
|
||||||
|
start_date: string
|
||||||
|
end_date: string
|
||||||
|
autopay_enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromoOffer {
|
||||||
|
id: number
|
||||||
|
user_id: number
|
||||||
|
subscription_id: number | null
|
||||||
|
notification_type: string
|
||||||
|
discount_percent: number
|
||||||
|
bonus_amount_kopeks: number
|
||||||
|
expires_at: string
|
||||||
|
claimed_at: string | null
|
||||||
|
is_active: boolean
|
||||||
|
effect_type: string
|
||||||
|
extra_data: Record<string, any>
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
user: PromoOfferUserInfo | null
|
||||||
|
subscription: PromoOfferSubscriptionInfo | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromoOfferListResponse {
|
||||||
|
items: PromoOffer[]
|
||||||
|
total: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromoOfferBroadcastRequest {
|
||||||
|
notification_type: string
|
||||||
|
valid_hours: number
|
||||||
|
discount_percent?: number
|
||||||
|
bonus_amount_kopeks?: number
|
||||||
|
effect_type?: string
|
||||||
|
extra_data?: Record<string, any>
|
||||||
|
target?: string
|
||||||
|
user_id?: number
|
||||||
|
telegram_id?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromoOfferBroadcastResponse {
|
||||||
|
created_offers: number
|
||||||
|
user_ids: number[]
|
||||||
|
target: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromoOfferTemplate {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
offer_type: string
|
||||||
|
message_text: string
|
||||||
|
button_text: string
|
||||||
|
valid_hours: number
|
||||||
|
discount_percent: number
|
||||||
|
bonus_amount_kopeks: number
|
||||||
|
active_discount_hours: number | null
|
||||||
|
test_duration_hours: number | null
|
||||||
|
test_squad_uuids: string[]
|
||||||
|
is_active: boolean
|
||||||
|
created_by: number | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromoOfferTemplateListResponse {
|
||||||
|
items: PromoOfferTemplate[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromoOfferTemplateUpdateRequest {
|
||||||
|
name?: string
|
||||||
|
message_text?: string
|
||||||
|
button_text?: string
|
||||||
|
valid_hours?: number
|
||||||
|
discount_percent?: number
|
||||||
|
bonus_amount_kopeks?: number
|
||||||
|
active_discount_hours?: number
|
||||||
|
test_duration_hours?: number
|
||||||
|
test_squad_uuids?: string[]
|
||||||
|
is_active?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromoOfferLogOfferInfo {
|
||||||
|
id: number
|
||||||
|
notification_type: string | null
|
||||||
|
discount_percent: number | null
|
||||||
|
bonus_amount_kopeks: number | null
|
||||||
|
effect_type: string | null
|
||||||
|
expires_at: string | null
|
||||||
|
claimed_at: string | null
|
||||||
|
is_active: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromoOfferLog {
|
||||||
|
id: number
|
||||||
|
user_id: number | null
|
||||||
|
offer_id: number | null
|
||||||
|
action: string
|
||||||
|
source: string | null
|
||||||
|
percent: number | null
|
||||||
|
effect_type: string | null
|
||||||
|
details: Record<string, any>
|
||||||
|
created_at: string
|
||||||
|
user: PromoOfferUserInfo | null
|
||||||
|
offer: PromoOfferLogOfferInfo | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromoOfferLogListResponse {
|
||||||
|
items: PromoOfferLog[]
|
||||||
|
total: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target segments for broadcast
|
||||||
|
export const TARGET_SEGMENTS = {
|
||||||
|
all: 'Все пользователи',
|
||||||
|
active: 'Активные подписчики',
|
||||||
|
trial: 'Триал пользователи',
|
||||||
|
trial_ending: 'Заканчивается триал',
|
||||||
|
expiring: 'Заканчивается подписка',
|
||||||
|
expired: 'Истекшая подписка',
|
||||||
|
zero: 'Нулевой баланс',
|
||||||
|
autopay_failed: 'Ошибка автоплатежа',
|
||||||
|
low_balance: 'Низкий баланс',
|
||||||
|
inactive_30d: 'Неактивны 30 дней',
|
||||||
|
inactive_60d: 'Неактивны 60 дней',
|
||||||
|
inactive_90d: 'Неактивны 90 дней',
|
||||||
|
custom_today: 'Зарегистрированы сегодня',
|
||||||
|
custom_week: 'Зарегистрированы за неделю',
|
||||||
|
custom_month: 'Зарегистрированы за месяц',
|
||||||
|
custom_active_today: 'Активны сегодня',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type TargetSegment = keyof typeof TARGET_SEGMENTS
|
||||||
|
|
||||||
|
// Offer type configurations
|
||||||
|
export const OFFER_TYPE_CONFIG = {
|
||||||
|
test_access: {
|
||||||
|
icon: '🧪',
|
||||||
|
label: 'Тестовый доступ',
|
||||||
|
effect: 'test_access',
|
||||||
|
description: 'Временный доступ к дополнительным серверам',
|
||||||
|
},
|
||||||
|
extend_discount: {
|
||||||
|
icon: '💎',
|
||||||
|
label: 'Скидка на продление',
|
||||||
|
effect: 'percent_discount',
|
||||||
|
description: 'Скидка для текущих подписчиков',
|
||||||
|
},
|
||||||
|
purchase_discount: {
|
||||||
|
icon: '🎯',
|
||||||
|
label: 'Скидка на покупку',
|
||||||
|
effect: 'percent_discount',
|
||||||
|
description: 'Скидка для новых пользователей',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type OfferType = keyof typeof OFFER_TYPE_CONFIG
|
||||||
|
|
||||||
|
// ============== API ==============
|
||||||
|
|
||||||
|
export const promoOffersApi = {
|
||||||
|
// Get list of promo offers
|
||||||
|
getOffers: async (params?: {
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
user_id?: number
|
||||||
|
is_active?: boolean
|
||||||
|
}): Promise<PromoOfferListResponse> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/promo-offers', { params })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Broadcast offer to multiple users
|
||||||
|
broadcastOffer: async (data: PromoOfferBroadcastRequest): Promise<PromoOfferBroadcastResponse> => {
|
||||||
|
const response = await apiClient.post('/cabinet/admin/promo-offers/broadcast', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get promo offer logs
|
||||||
|
getLogs: async (params?: {
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
user_id?: number
|
||||||
|
action?: string
|
||||||
|
}): Promise<PromoOfferLogListResponse> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/promo-offers/logs', { params })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get all templates
|
||||||
|
getTemplates: async (): Promise<PromoOfferTemplateListResponse> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/promo-offers/templates')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get single template
|
||||||
|
getTemplate: async (id: number): Promise<PromoOfferTemplate> => {
|
||||||
|
const response = await apiClient.get(`/cabinet/admin/promo-offers/templates/${id}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update template
|
||||||
|
updateTemplate: async (id: number, data: PromoOfferTemplateUpdateRequest): Promise<PromoOfferTemplate> => {
|
||||||
|
const response = await apiClient.patch(`/cabinet/admin/promo-offers/templates/${id}`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
153
src/components/PromoDiscountBadge.tsx
Normal file
153
src/components/PromoDiscountBadge.tsx
Normal file
@@ -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 = () => (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<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 ClockIcon = () => (
|
||||||
|
<svg className="w-3.5 h-3.5" 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 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<HTMLDivElement>(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 (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
{/* Badge button */}
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className="relative flex items-center gap-1.5 px-3 py-2 rounded-xl bg-gradient-to-r from-success-500/20 to-accent-500/20 border border-success-500/30 hover:border-success-500/50 transition-all group"
|
||||||
|
title={t('promo.activeDiscount', 'Active discount')}
|
||||||
|
>
|
||||||
|
{/* Animated sparkle effect */}
|
||||||
|
<div className="absolute inset-0 rounded-xl bg-gradient-to-r from-success-500/10 to-accent-500/10 animate-pulse" />
|
||||||
|
|
||||||
|
<SparklesIcon />
|
||||||
|
<span className="relative font-bold text-success-400 text-sm">
|
||||||
|
-{activeDiscount.discount_percent}%
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Notification dot */}
|
||||||
|
<span className="absolute -top-1 -right-1 w-2 h-2 bg-success-400 rounded-full animate-pulse" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown - mobile: fixed centered, desktop: absolute right */}
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
{/* Mobile backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/30 z-40 sm:hidden"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="fixed left-4 right-4 top-20 sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-72 bg-dark-800 rounded-xl shadow-xl border border-dark-700/50 z-50 animate-fade-in overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gradient-to-r from-success-500/20 to-accent-500/20 p-4 border-b border-dark-700/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-success-500/20 flex items-center justify-center text-success-400">
|
||||||
|
<SparklesIcon />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-dark-100">
|
||||||
|
{t('promo.discountActive')}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-success-400">
|
||||||
|
-{activeDiscount.discount_percent}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<p className="text-sm text-dark-300">
|
||||||
|
{t('promo.discountDescription')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Time remaining */}
|
||||||
|
{timeLeft && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-dark-400 bg-dark-900/50 px-3 py-2 rounded-lg">
|
||||||
|
<ClockIcon />
|
||||||
|
<span>{t('promo.expiresIn')}: <span className="text-warning-400 font-medium">{timeLeft}</span></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CTA Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleGoToSubscription}
|
||||||
|
className="w-full btn-primary py-2.5 text-sm font-medium"
|
||||||
|
>
|
||||||
|
{t('promo.useNow')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
252
src/components/PromoOffersSection.tsx
Normal file
252
src/components/PromoOffersSection.tsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { promoApi, PromoOffer } from '../api/promo'
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
const GiftIcon = () => (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ClockIcon = () => (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SparklesIcon = () => (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const CheckIcon = () => (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ServerIcon = () => (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
const formatTimeLeft = (expiresAt: string): string => {
|
||||||
|
const now = new Date()
|
||||||
|
const expires = new Date(expiresAt)
|
||||||
|
const diffMs = expires.getTime() - now.getTime()
|
||||||
|
|
||||||
|
if (diffMs <= 0) return 'Истекло'
|
||||||
|
|
||||||
|
const hours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||||
|
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
|
||||||
|
|
||||||
|
if (hours > 24) {
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
return `${days} дн.`
|
||||||
|
}
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}ч ${minutes}м`
|
||||||
|
}
|
||||||
|
return `${minutes}м`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOfferIcon = (effectType: string) => {
|
||||||
|
if (effectType === 'test_access') return <ServerIcon />
|
||||||
|
return <SparklesIcon />
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOfferTitle = (offer: PromoOffer): string => {
|
||||||
|
if (offer.effect_type === 'test_access') {
|
||||||
|
return 'Тестовый доступ'
|
||||||
|
}
|
||||||
|
if (offer.discount_percent) {
|
||||||
|
return `Скидка ${offer.discount_percent}%`
|
||||||
|
}
|
||||||
|
return 'Специальное предложение'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOfferDescription = (offer: PromoOffer): string => {
|
||||||
|
if (offer.effect_type === 'test_access') {
|
||||||
|
const squads = offer.extra_data?.test_squad_uuids?.length || 0
|
||||||
|
return squads > 0 ? `Доступ к ${squads} серверам` : 'Доступ к дополнительным серверам'
|
||||||
|
}
|
||||||
|
return 'Активируйте скидку на покупку подписки'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PromoOffersSectionProps {
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PromoOffersSection({ className = '' }: PromoOffersSectionProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [claimingId, setClaimingId] = useState<number | null>(null)
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null)
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Fetch available offers
|
||||||
|
const { data: offers = [], isLoading: offersLoading } = useQuery({
|
||||||
|
queryKey: ['promo-offers'],
|
||||||
|
queryFn: promoApi.getOffers,
|
||||||
|
staleTime: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch active discount
|
||||||
|
const { data: activeDiscount } = useQuery({
|
||||||
|
queryKey: ['active-discount'],
|
||||||
|
queryFn: promoApi.getActiveDiscount,
|
||||||
|
staleTime: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Claim offer mutation
|
||||||
|
const claimMutation = useMutation({
|
||||||
|
mutationFn: promoApi.claimOffer,
|
||||||
|
onSuccess: (result) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['promo-offers'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['active-discount'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['subscription'] })
|
||||||
|
setSuccessMessage(result.message)
|
||||||
|
setClaimingId(null)
|
||||||
|
setTimeout(() => setSuccessMessage(null), 5000)
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setErrorMessage(error.response?.data?.detail || 'Не удалось активировать предложение')
|
||||||
|
setClaimingId(null)
|
||||||
|
setTimeout(() => setErrorMessage(null), 5000)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleClaim = (offerId: number) => {
|
||||||
|
setClaimingId(offerId)
|
||||||
|
setErrorMessage(null)
|
||||||
|
setSuccessMessage(null)
|
||||||
|
claimMutation.mutate(offerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter unclaimed and active offers
|
||||||
|
const availableOffers = offers.filter(o => o.is_active && !o.is_claimed)
|
||||||
|
|
||||||
|
// Don't render if no offers and no active discount
|
||||||
|
if (!offersLoading && availableOffers.length === 0 && (!activeDiscount || !activeDiscount.is_active)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`space-y-4 ${className}`}>
|
||||||
|
{/* Active Discount Banner */}
|
||||||
|
{activeDiscount && activeDiscount.is_active && activeDiscount.discount_percent > 0 && (
|
||||||
|
<div className="card border-accent-500/30 bg-gradient-to-br from-accent-500/10 to-transparent">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-accent-500/20 flex items-center justify-center flex-shrink-0 text-accent-400">
|
||||||
|
<CheckIcon />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="font-semibold text-dark-100">
|
||||||
|
Скидка {activeDiscount.discount_percent}% активна
|
||||||
|
</h3>
|
||||||
|
<span className="px-2 py-0.5 text-xs bg-accent-500/20 text-accent-400 rounded">
|
||||||
|
Действует
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-dark-400">
|
||||||
|
{activeDiscount.expires_at && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ClockIcon />
|
||||||
|
<span>Истекает: {formatTimeLeft(activeDiscount.expires_at)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success/Error Messages */}
|
||||||
|
{successMessage && (
|
||||||
|
<div className="p-4 bg-success-500/10 border border-success-500/30 text-success-400 rounded-xl flex items-center gap-3">
|
||||||
|
<CheckIcon />
|
||||||
|
<span>{successMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="p-4 bg-error-500/10 border border-error-500/30 text-error-400 rounded-xl">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Available Offers */}
|
||||||
|
{availableOffers.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{availableOffers.map((offer) => (
|
||||||
|
<div
|
||||||
|
key={offer.id}
|
||||||
|
className="card border-orange-500/30 bg-gradient-to-br from-orange-500/5 to-transparent hover:border-orange-500/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-orange-500/20 flex items-center justify-center flex-shrink-0 text-orange-400">
|
||||||
|
{getOfferIcon(offer.effect_type)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="font-semibold text-dark-100">
|
||||||
|
{getOfferTitle(offer)}
|
||||||
|
</h3>
|
||||||
|
{offer.effect_type === 'test_access' && (
|
||||||
|
<span className="px-2 py-0.5 text-xs bg-purple-500/20 text-purple-400 rounded">
|
||||||
|
Тест
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-dark-400 mb-3">
|
||||||
|
{getOfferDescription(offer)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-dark-500">
|
||||||
|
<ClockIcon />
|
||||||
|
<span>Осталось: {formatTimeLeft(offer.expires_at)}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleClaim(offer.id)}
|
||||||
|
disabled={claimingId === offer.id}
|
||||||
|
className="px-4 py-2 bg-orange-500 text-white text-sm font-medium rounded-lg hover:bg-orange-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{claimingId === offer.id ? (
|
||||||
|
<>
|
||||||
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span>Активация...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<GiftIcon />
|
||||||
|
<span>Активировать</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{offersLoading && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-dark-700 animate-pulse" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-5 w-32 bg-dark-700 rounded animate-pulse" />
|
||||||
|
<div className="h-4 w-48 bg-dark-700 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,7 +19,10 @@ const buildCryptoBotDeepLink = (url: string): string | null => {
|
|||||||
return `tg://resolve?domain=CryptoBot${parsed.search || ''}`
|
return `tg://resolve?domain=CryptoBot${parsed.search || ''}`
|
||||||
}
|
}
|
||||||
return null
|
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) => {
|
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 inside Telegram Mini App, let Telegram handle t.me links
|
||||||
if (isTelegramPaymentLink(url) && webApp?.openTelegramLink) {
|
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
|
// 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
|
const target = cb && !isTelegramPaymentLink(url) ? cb : url
|
||||||
|
|
||||||
if (reservedWindow && !reservedWindow.closed) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,28 +75,20 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) {
|
|||||||
const starsPaymentMutation = useMutation({
|
const starsPaymentMutation = useMutation({
|
||||||
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
|
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
console.log('[Stars] API response:', data)
|
|
||||||
console.log('[Stars] invoice_url:', data.invoice_url)
|
|
||||||
const webApp = window.Telegram?.WebApp
|
const webApp = window.Telegram?.WebApp
|
||||||
console.log('[Stars] webApp:', webApp)
|
|
||||||
console.log('[Stars] openInvoice available:', !!webApp?.openInvoice)
|
|
||||||
|
|
||||||
if (!data.invoice_url) {
|
if (!data.invoice_url) {
|
||||||
console.error('[Stars] No invoice_url in response!')
|
|
||||||
setError('Сервер не вернул ссылку на оплату')
|
setError('Сервер не вернул ссылку на оплату')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!webApp?.openInvoice) {
|
if (!webApp?.openInvoice) {
|
||||||
console.error('[Stars] openInvoice not available - not in Telegram Mini App?')
|
|
||||||
setError('Оплата Stars доступна только в Telegram Mini App')
|
setError('Оплата Stars доступна только в Telegram Mini App')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Stars] Calling openInvoice with:', data.invoice_url)
|
|
||||||
try {
|
try {
|
||||||
webApp.openInvoice(data.invoice_url, (status) => {
|
webApp.openInvoice(data.invoice_url, (status) => {
|
||||||
console.log('[Stars] Invoice callback status:', status)
|
|
||||||
if (status === 'paid') {
|
if (status === 'paid') {
|
||||||
setError(null)
|
setError(null)
|
||||||
onClose()
|
onClose()
|
||||||
@@ -104,12 +99,10 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Stars] openInvoice error:', e)
|
|
||||||
setError('Ошибка открытия окна оплаты: ' + String(e))
|
setError('Ошибка открытия окна оплаты: ' + String(e))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
console.error('[Stars] API error:', error)
|
|
||||||
const axiosError = error as { response?: { data?: { detail?: string }, status?: number } }
|
const axiosError = error as { response?: { data?: { detail?: string }, status?: number } }
|
||||||
const detail = axiosError?.response?.data?.detail
|
const detail = axiosError?.response?.data?.detail
|
||||||
const status = axiosError?.response?.status
|
const status = axiosError?.response?.status
|
||||||
@@ -136,7 +129,7 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) {
|
|||||||
onClose()
|
onClose()
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
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
|
popupRef.current = null
|
||||||
const detail = (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail || ''
|
const detail = (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail || ''
|
||||||
if (detail.includes('not yet implemented')) setError(t('balance.useBot'))
|
if (detail.includes('not yet implemented')) setError(t('balance.useBot'))
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from 'react'
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useAuthStore } from '../../store/auth'
|
import { useAuthStore } from '../../store/auth'
|
||||||
import LanguageSwitcher from '../LanguageSwitcher'
|
import LanguageSwitcher from '../LanguageSwitcher'
|
||||||
|
import PromoDiscountBadge from '../PromoDiscountBadge'
|
||||||
import { contestsApi } from '../../api/contests'
|
import { contestsApi } from '../../api/contests'
|
||||||
import { pollsApi } from '../../api/polls'
|
import { pollsApi } from '../../api/polls'
|
||||||
import { brandingApi } from '../../api/branding'
|
import { brandingApi } from '../../api/branding'
|
||||||
@@ -258,7 +259,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="sticky top-0 z-50 glass border-b border-dark-800/50">
|
<header className="sticky top-0 z-50 glass border-b border-dark-800/50">
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
<div className="w-full mx-auto px-4 sm:px-6">
|
||||||
<div className="flex justify-between items-center h-16 lg:h-20">
|
<div className="flex justify-between items-center h-16 lg:h-20">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link to="/" className={`flex items-center gap-2.5 flex-shrink-0 ${!appName ? 'lg:mr-4' : ''}`}>
|
<Link to="/" className={`flex items-center gap-2.5 flex-shrink-0 ${!appName ? 'lg:mr-4' : ''}`}>
|
||||||
@@ -335,6 +336,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<PromoDiscountBadge />
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
|
|
||||||
{/* Profile - Desktop */}
|
{/* Profile - Desktop */}
|
||||||
|
|||||||
@@ -307,6 +307,12 @@
|
|||||||
"name": "FreeKassa",
|
"name": "FreeKassa",
|
||||||
"description": "Pay via FreeKassa"
|
"description": "Pay via FreeKassa"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"pendingPayments": {
|
||||||
|
"title": "Pending Payments",
|
||||||
|
"pay": "Pay",
|
||||||
|
"checkStatus": "Check Status",
|
||||||
|
"checking": "Checking..."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"referral": {
|
"referral": {
|
||||||
@@ -443,7 +449,10 @@
|
|||||||
"tariffs": "Tariffs",
|
"tariffs": "Tariffs",
|
||||||
"servers": "Servers",
|
"servers": "Servers",
|
||||||
"banSystem": "Ban Monitoring",
|
"banSystem": "Ban Monitoring",
|
||||||
"broadcasts": "Broadcasts"
|
"broadcasts": "Broadcasts",
|
||||||
|
"users": "Users",
|
||||||
|
"payments": "Payments",
|
||||||
|
"remnawave": "RemnaWave"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "Admin Panel",
|
"title": "Admin Panel",
|
||||||
@@ -456,7 +465,85 @@
|
|||||||
"tariffsDesc": "Manage tariff plans",
|
"tariffsDesc": "Manage tariff plans",
|
||||||
"serversDesc": "Configure VPN servers",
|
"serversDesc": "Configure VPN servers",
|
||||||
"banSystemDesc": "Ban monitoring and violations",
|
"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": {
|
"wheel": {
|
||||||
"title": "Fortune Wheel Settings",
|
"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."
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -762,5 +762,19 @@
|
|||||||
"description": "از این دکمهها برای دسترسی سریع به امکانات اصلی استفاده کنید: شارژ موجودی، تمدید اشتراک و دعوت دوستان."
|
"description": "از این دکمهها برای دسترسی سریع به امکانات اصلی استفاده کنید: شارژ موجودی، تمدید اشتراک و دعوت دوستان."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"promo": {
|
||||||
|
"activeDiscount": "تخفیف فعال",
|
||||||
|
"discountActive": "تخفیف فعال است!",
|
||||||
|
"discountDescription": "تخفیف شخصی شما برای خرید اشتراک اعمال میشود",
|
||||||
|
"expiresIn": "منقضی میشود در",
|
||||||
|
"source": "منبع",
|
||||||
|
"useNow": "همین الان استفاده کن",
|
||||||
|
"discountApplied": "تخفیف اعمال شد",
|
||||||
|
"time": {
|
||||||
|
"days": "روز",
|
||||||
|
"hours": "ساعت",
|
||||||
|
"minutes": "دقیقه"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
"error": "Ошибка",
|
"error": "Ошибка",
|
||||||
@@ -307,6 +307,12 @@
|
|||||||
"name": "FreeKassa",
|
"name": "FreeKassa",
|
||||||
"description": "Оплата через FreeKassa"
|
"description": "Оплата через FreeKassa"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"pendingPayments": {
|
||||||
|
"title": "Ожидающие платежи",
|
||||||
|
"pay": "Оплатить",
|
||||||
|
"checkStatus": "Проверить статус",
|
||||||
|
"checking": "Проверка..."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"referral": {
|
"referral": {
|
||||||
@@ -444,7 +450,9 @@
|
|||||||
"servers": "Серверы",
|
"servers": "Серверы",
|
||||||
"banSystem": "Мониторинг банов",
|
"banSystem": "Мониторинг банов",
|
||||||
"broadcasts": "Рассылки",
|
"broadcasts": "Рассылки",
|
||||||
"users": "Пользователи"
|
"users": "Пользователи",
|
||||||
|
"payments": "Платежи",
|
||||||
|
"remnawave": "RemnaWave"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "Панель администратора",
|
"title": "Панель администратора",
|
||||||
@@ -458,7 +466,84 @@
|
|||||||
"serversDesc": "Настройка VPN серверов",
|
"serversDesc": "Настройка VPN серверов",
|
||||||
"banSystemDesc": "Мониторинг банов и нарушений",
|
"banSystemDesc": "Мониторинг банов и нарушений",
|
||||||
"broadcastsDesc": "Массовая отправка сообщений",
|
"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": {
|
"wheel": {
|
||||||
"title": "Настройки колеса удачи",
|
"title": "Настройки колеса удачи",
|
||||||
@@ -1339,5 +1424,19 @@
|
|||||||
"description": "Используйте эти кнопки для быстрого доступа к основным функциям: пополнению баланса, продлению подписки и приглашению друзей."
|
"description": "Используйте эти кнопки для быстрого доступа к основным функциям: пополнению баланса, продлению подписки и приглашению друзей."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"promo": {
|
||||||
|
"activeDiscount": "Активная скидка",
|
||||||
|
"discountActive": "Скидка активна!",
|
||||||
|
"discountDescription": "Ваша персональная скидка применяется к покупке подписки",
|
||||||
|
"expiresIn": "Истекает через",
|
||||||
|
"source": "Источник",
|
||||||
|
"useNow": "Использовать сейчас",
|
||||||
|
"discountApplied": "Скидка применена",
|
||||||
|
"time": {
|
||||||
|
"days": "д",
|
||||||
|
"hours": "ч",
|
||||||
|
"minutes": "м"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -762,5 +762,19 @@
|
|||||||
"description": "使用这些按钮快速访问主要功能:充值余额、延长订阅和邀请好友。"
|
"description": "使用这些按钮快速访问主要功能:充值余额、延长订阅和邀请好友。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"promo": {
|
||||||
|
"activeDiscount": "活动折扣",
|
||||||
|
"discountActive": "折扣已激活!",
|
||||||
|
"discountDescription": "您的个人折扣适用于订阅购买",
|
||||||
|
"expiresIn": "到期时间",
|
||||||
|
"source": "来源",
|
||||||
|
"useNow": "立即使用",
|
||||||
|
"discountApplied": "折扣已应用",
|
||||||
|
"time": {
|
||||||
|
"days": "天",
|
||||||
|
"hours": "时",
|
||||||
|
"minutes": "分"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +81,24 @@ const UsersIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
|
|||||||
</svg>
|
</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 = () => (
|
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}>
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
@@ -230,6 +248,16 @@ export default function AdminPanel() {
|
|||||||
bgColor: 'bg-violet-500/20',
|
bgColor: 'bg-violet-500/20',
|
||||||
textColor: 'text-violet-400'
|
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',
|
to: '/admin/campaigns',
|
||||||
icon: <CampaignIcon />,
|
icon: <CampaignIcon />,
|
||||||
@@ -260,6 +288,26 @@ export default function AdminPanel() {
|
|||||||
bgColor: 'bg-red-500/20',
|
bgColor: 'bg-red-500/20',
|
||||||
textColor: 'text-red-400'
|
textColor: 'text-red-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 (
|
return (
|
||||||
|
|||||||
283
src/pages/AdminPayments.tsx
Normal file
283
src/pages/AdminPayments.tsx
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { adminPaymentsApi } from '../api/adminPayments'
|
||||||
|
import { useCurrency } from '../hooks/useCurrency'
|
||||||
|
import type { PendingPayment, PaginatedResponse } from '../types'
|
||||||
|
|
||||||
|
export default function AdminPayments() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { formatAmount, currencySymbol } = useCurrency()
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [methodFilter, setMethodFilter] = useState<string>('')
|
||||||
|
const [checkingPaymentId, setCheckingPaymentId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Fetch payments
|
||||||
|
const { data: payments, isLoading, refetch } = useQuery<PaginatedResponse<PendingPayment>>({
|
||||||
|
queryKey: ['admin-payments', page, methodFilter],
|
||||||
|
queryFn: () => adminPaymentsApi.getPendingPayments({
|
||||||
|
page,
|
||||||
|
per_page: 20,
|
||||||
|
method_filter: methodFilter || undefined,
|
||||||
|
}),
|
||||||
|
refetchInterval: 30000, // Auto-refresh every 30 seconds
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch stats
|
||||||
|
const { data: stats } = useQuery({
|
||||||
|
queryKey: ['admin-payments-stats'],
|
||||||
|
queryFn: adminPaymentsApi.getStats,
|
||||||
|
refetchInterval: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check payment mutation
|
||||||
|
const checkPaymentMutation = useMutation({
|
||||||
|
mutationFn: ({ method, paymentId }: { method: string; paymentId: number }) =>
|
||||||
|
adminPaymentsApi.checkPaymentStatus(method, paymentId),
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
if (result.status_changed) {
|
||||||
|
await refetch()
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-payments-stats'] })
|
||||||
|
} else {
|
||||||
|
await refetch()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setCheckingPaymentId(null)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCheckPayment = (payment: PendingPayment) => {
|
||||||
|
setCheckingPaymentId(`${payment.method}_${payment.id}`)
|
||||||
|
checkPaymentMutation.mutate({ method: payment.method, paymentId: payment.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unique methods from stats for filter
|
||||||
|
const methodOptions = stats?.by_method ? Object.keys(stats.by_method) : []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-dark-50">{t('admin.payments.title', 'Проверка платежей')}</h1>
|
||||||
|
<p className="text-sm text-dark-400">{t('admin.payments.description', 'Ожидающие платежи за последние 24 часа')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="btn-secondary flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||||
|
</svg>
|
||||||
|
{t('common.refresh', 'Обновить')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats cards */}
|
||||||
|
{stats && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||||
|
<div className="p-4 rounded-xl bg-dark-800/50 border border-dark-700/50">
|
||||||
|
<div className="text-2xl font-bold text-dark-50">{stats.total_pending}</div>
|
||||||
|
<div className="text-sm text-dark-400">{t('admin.payments.totalPending', 'Всего ожидает')}</div>
|
||||||
|
</div>
|
||||||
|
{Object.entries(stats.by_method).map(([method, count]) => (
|
||||||
|
<div
|
||||||
|
key={method}
|
||||||
|
className={`p-4 rounded-xl border cursor-pointer transition-all ${
|
||||||
|
methodFilter === method
|
||||||
|
? 'bg-accent-500/20 border-accent-500/50'
|
||||||
|
: 'bg-dark-800/50 border-dark-700/50 hover:border-dark-600'
|
||||||
|
}`}
|
||||||
|
onClick={() => setMethodFilter(methodFilter === method ? '' : method)}
|
||||||
|
>
|
||||||
|
<div className="text-2xl font-bold text-dark-50">{count}</div>
|
||||||
|
<div className="text-sm text-dark-400">{method}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
{methodOptions.length > 0 && (
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<span className="text-sm text-dark-400">{t('admin.payments.filterByMethod', 'Фильтр по методу')}:</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setMethodFilter('')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm transition-all ${
|
||||||
|
methodFilter === ''
|
||||||
|
? 'bg-accent-500 text-white'
|
||||||
|
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('common.all', 'Все')}
|
||||||
|
</button>
|
||||||
|
{methodOptions.map((method) => (
|
||||||
|
<button
|
||||||
|
key={method}
|
||||||
|
onClick={() => setMethodFilter(method)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm transition-all ${
|
||||||
|
methodFilter === method
|
||||||
|
? 'bg-accent-500 text-white'
|
||||||
|
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{method}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payments list */}
|
||||||
|
<div className="card">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : payments?.items && payments.items.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{payments.items.map((payment) => {
|
||||||
|
const paymentKey = `${payment.method}_${payment.id}`
|
||||||
|
const isChecking = checkingPaymentId === paymentKey
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={paymentKey}
|
||||||
|
className="p-4 rounded-xl bg-dark-800/30 border border-dark-700/30"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<span className="font-semibold text-dark-100">{payment.method_display}</span>
|
||||||
|
<span className="text-sm px-2 py-0.5 rounded-full bg-dark-700/50 text-dark-300">
|
||||||
|
{payment.status_emoji} {payment.status_text}
|
||||||
|
</span>
|
||||||
|
{payment.is_paid && (
|
||||||
|
<span className="text-sm px-2 py-0.5 rounded-full bg-success-500/20 text-success-400">
|
||||||
|
{t('admin.payments.paid', 'Оплачено')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold text-dark-50">
|
||||||
|
{formatAmount(payment.amount_rubles)} {currencySymbol}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-dark-400 mt-1">
|
||||||
|
ID: <code className="text-dark-300">{payment.identifier}</code>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-dark-500 mt-1">
|
||||||
|
{new Date(payment.created_at).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
{/* User info */}
|
||||||
|
{(payment.user_username || payment.user_telegram_id) && (
|
||||||
|
<div className="text-sm text-dark-400 mt-2">
|
||||||
|
<span className="text-dark-500">{t('admin.payments.user', 'Пользователь')}:</span>{' '}
|
||||||
|
{payment.user_username ? (
|
||||||
|
<span className="text-dark-200">@{payment.user_username}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-dark-300">ID: {payment.user_telegram_id}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{payment.payment_url && (
|
||||||
|
<a
|
||||||
|
href={payment.payment_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn-secondary text-xs px-3 py-1.5"
|
||||||
|
>
|
||||||
|
{t('admin.payments.openLink', 'Открыть ссылку')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{payment.is_checkable && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleCheckPayment(payment)}
|
||||||
|
disabled={isChecking}
|
||||||
|
className="btn-primary text-xs px-3 py-1.5"
|
||||||
|
>
|
||||||
|
{isChecking ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
{t('admin.payments.checking', 'Проверка...')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
t('admin.payments.checkStatus', 'Проверить статус')
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Show result after check */}
|
||||||
|
{checkPaymentMutation.isSuccess && checkPaymentMutation.variables?.paymentId === payment.id && (
|
||||||
|
<div className={`mt-3 p-2 rounded-lg text-sm ${
|
||||||
|
checkPaymentMutation.data?.status_changed
|
||||||
|
? 'bg-success-500/10 border border-success-500/30 text-success-400'
|
||||||
|
: 'bg-dark-700/30 text-dark-400'
|
||||||
|
}`}>
|
||||||
|
{checkPaymentMutation.data?.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
|
||||||
|
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-dark-400">{t('admin.payments.noPayments', 'Нет ожидающих платежей')}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{payments && payments.pages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center gap-3 flex-wrap text-sm text-dark-500">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
||||||
|
disabled={payments.page <= 1}
|
||||||
|
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[100px] ${
|
||||||
|
payments.page <= 1 ? 'opacity-50 cursor-not-allowed' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('common.back', 'Назад')}
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 text-center">
|
||||||
|
{t('balance.page', '{current} / {total}', { current: payments.page, total: payments.pages })}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage((prev) => Math.min(payments.pages, prev + 1))}
|
||||||
|
disabled={payments.page >= payments.pages}
|
||||||
|
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[100px] ${
|
||||||
|
payments.page >= payments.pages ? 'opacity-50 cursor-not-allowed' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('common.next', 'Далее')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
786
src/pages/AdminPromoOffers.tsx
Normal file
786
src/pages/AdminPromoOffers.tsx
Normal file
@@ -0,0 +1,786 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
promoOffersApi,
|
||||||
|
PromoOfferTemplate,
|
||||||
|
PromoOfferTemplateUpdateRequest,
|
||||||
|
PromoOfferLog,
|
||||||
|
TARGET_SEGMENTS,
|
||||||
|
TargetSegment,
|
||||||
|
OFFER_TYPE_CONFIG,
|
||||||
|
OfferType,
|
||||||
|
} from '../api/promoOffers'
|
||||||
|
|
||||||
|
// Icons
|
||||||
|
const GiftIcon = () => (
|
||||||
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const EditIcon = () => (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const XIcon = () => (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SendIcon = () => (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ClockIcon = () => (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const UserIcon = () => (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const CheckIcon = () => (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const UsersIcon = () => (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
const formatDateTime = (date: string | null): string => {
|
||||||
|
if (!date) return '-'
|
||||||
|
return new Date(date).toLocaleString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActionLabel = (action: string): string => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
created: 'Создано',
|
||||||
|
claimed: 'Активировано',
|
||||||
|
consumed: 'Использовано',
|
||||||
|
disabled: 'Деактивировано',
|
||||||
|
}
|
||||||
|
return labels[action] || action
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActionColor = (action: string): string => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
created: 'bg-blue-500/20 text-blue-400',
|
||||||
|
claimed: 'bg-emerald-500/20 text-emerald-400',
|
||||||
|
consumed: 'bg-purple-500/20 text-purple-400',
|
||||||
|
disabled: 'bg-dark-600 text-dark-400',
|
||||||
|
}
|
||||||
|
return colors[action] || 'bg-dark-600 text-dark-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOfferTypeIcon = (offerType: string): string => {
|
||||||
|
return OFFER_TYPE_CONFIG[offerType as OfferType]?.icon || '🎁'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOfferTypeLabel = (offerType: string): string => {
|
||||||
|
return OFFER_TYPE_CONFIG[offerType as OfferType]?.label || offerType
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template Edit Modal
|
||||||
|
interface TemplateEditModalProps {
|
||||||
|
template: PromoOfferTemplate
|
||||||
|
onSave: (data: PromoOfferTemplateUpdateRequest) => void
|
||||||
|
onClose: () => void
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEditModalProps) {
|
||||||
|
const [name, setName] = useState(template.name)
|
||||||
|
const [messageText, setMessageText] = useState(template.message_text)
|
||||||
|
const [buttonText, setButtonText] = useState(template.button_text)
|
||||||
|
const [validHours, setValidHours] = useState(template.valid_hours)
|
||||||
|
const [discountPercent, setDiscountPercent] = useState(template.discount_percent)
|
||||||
|
const [activeDiscountHours, setActiveDiscountHours] = useState(template.active_discount_hours || 0)
|
||||||
|
const [testDurationHours, setTestDurationHours] = useState(template.test_duration_hours || 0)
|
||||||
|
const [isActive, setIsActive] = useState(template.is_active)
|
||||||
|
|
||||||
|
const isTestAccess = template.offer_type === 'test_access'
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const data: PromoOfferTemplateUpdateRequest = {
|
||||||
|
name,
|
||||||
|
message_text: messageText,
|
||||||
|
button_text: buttonText,
|
||||||
|
valid_hours: validHours,
|
||||||
|
discount_percent: discountPercent,
|
||||||
|
is_active: isActive,
|
||||||
|
}
|
||||||
|
if (isTestAccess) {
|
||||||
|
data.test_duration_hours = testDurationHours > 0 ? testDurationHours : undefined
|
||||||
|
} else {
|
||||||
|
data.active_discount_hours = activeDiscountHours > 0 ? activeDiscountHours : undefined
|
||||||
|
}
|
||||||
|
onSave(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-dark-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-2xl">{getOfferTypeIcon(template.offer_type)}</span>
|
||||||
|
<h2 className="text-lg font-semibold text-dark-100">
|
||||||
|
Редактирование шаблона
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
|
||||||
|
<XIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-dark-300 mb-1">Название шаблона</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-dark-300 mb-1">Текст сообщения</label>
|
||||||
|
<textarea
|
||||||
|
value={messageText}
|
||||||
|
onChange={e => setMessageText(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-dark-300 mb-1">Текст кнопки</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={buttonText}
|
||||||
|
onChange={e => setButtonText(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-dark-300 mb-1">Срок предложения (часы)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={validHours}
|
||||||
|
onChange={e => setValidHours(Math.max(1, parseInt(e.target.value) || 1))}
|
||||||
|
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-dark-500 mt-1">Время на активацию</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isTestAccess && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-dark-300 mb-1">Скидка (%)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={discountPercent}
|
||||||
|
onChange={e => setDiscountPercent(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))}
|
||||||
|
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isTestAccess ? (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-dark-300 mb-1">Длительность доступа (часы)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={testDurationHours}
|
||||||
|
onChange={e => setTestDurationHours(Math.max(0, parseInt(e.target.value) || 0))}
|
||||||
|
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-dark-500 mt-1">0 = по умолчанию</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-dark-300 mb-1">Действие скидки (часы)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={activeDiscountHours}
|
||||||
|
onChange={e => setActiveDiscountHours(Math.max(0, parseInt(e.target.value) || 0))}
|
||||||
|
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-dark-500 mt-1">Сколько действует скидка после активации</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsActive(!isActive)}
|
||||||
|
className={`w-10 h-6 rounded-full transition-colors relative ${
|
||||||
|
isActive ? 'bg-accent-500' : 'bg-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||||
|
isActive ? 'left-5' : 'left-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-dark-200">Шаблон активен</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!name.trim() || isLoading}
|
||||||
|
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send Offer Modal
|
||||||
|
interface SendOfferModalProps {
|
||||||
|
templates: PromoOfferTemplate[]
|
||||||
|
onSend: (templateId: number, target: string | null, userId: number | null) => void
|
||||||
|
onClose: () => void
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModalProps) {
|
||||||
|
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(templates[0]?.id || null)
|
||||||
|
const [sendMode, setSendMode] = useState<'segment' | 'user'>('segment')
|
||||||
|
const [selectedTarget, setSelectedTarget] = useState<TargetSegment>('active')
|
||||||
|
const [userId, setUserId] = useState('')
|
||||||
|
|
||||||
|
const selectedTemplate = templates.find(t => t.id === selectedTemplateId)
|
||||||
|
const activeTemplates = templates.filter(t => t.is_active)
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!selectedTemplateId) return
|
||||||
|
if (sendMode === 'user') {
|
||||||
|
const id = parseInt(userId)
|
||||||
|
if (!id) return
|
||||||
|
onSend(selectedTemplateId, null, id)
|
||||||
|
} else {
|
||||||
|
onSend(selectedTemplateId, selectedTarget, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = () => {
|
||||||
|
if (!selectedTemplateId) return false
|
||||||
|
if (sendMode === 'user' && !userId.trim()) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-dark-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-accent-500/20 rounded-lg">
|
||||||
|
<SendIcon />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-dark-100">
|
||||||
|
Отправка промопредложения
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
|
||||||
|
<XIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{/* Template Selection */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-dark-300 mb-2">Шаблон предложения</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{activeTemplates.map(template => (
|
||||||
|
<button
|
||||||
|
key={template.id}
|
||||||
|
onClick={() => setSelectedTemplateId(template.id)}
|
||||||
|
className={`w-full p-3 rounded-lg border text-left transition-colors ${
|
||||||
|
selectedTemplateId === template.id
|
||||||
|
? 'border-accent-500 bg-accent-500/10'
|
||||||
|
: 'border-dark-600 bg-dark-700 hover:border-dark-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl">{getOfferTypeIcon(template.offer_type)}</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-dark-100">{template.name}</div>
|
||||||
|
<div className="text-sm text-dark-400">
|
||||||
|
{template.discount_percent > 0 && `${template.discount_percent}% скидка`}
|
||||||
|
{template.offer_type === 'test_access' && 'Тестовый доступ'}
|
||||||
|
<span className="mx-1">•</span>
|
||||||
|
{template.valid_hours}ч на активацию
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedTemplateId === template.id && (
|
||||||
|
<CheckIcon />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Send Mode */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-dark-300 mb-2">Кому отправить</label>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setSendMode('segment')}
|
||||||
|
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||||
|
sendMode === 'segment'
|
||||||
|
? 'border-accent-500 bg-accent-500/10 text-accent-400'
|
||||||
|
: 'border-dark-600 text-dark-400 hover:text-dark-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<UsersIcon />
|
||||||
|
<span className="ml-2">Сегмент</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSendMode('user')}
|
||||||
|
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||||
|
sendMode === 'user'
|
||||||
|
? 'border-accent-500 bg-accent-500/10 text-accent-400'
|
||||||
|
: 'border-dark-600 text-dark-400 hover:text-dark-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<UserIcon />
|
||||||
|
<span className="ml-2">Пользователь</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sendMode === 'segment' ? (
|
||||||
|
<select
|
||||||
|
value={selectedTarget}
|
||||||
|
onChange={e => setSelectedTarget(e.target.value as TargetSegment)}
|
||||||
|
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||||
|
>
|
||||||
|
{Object.entries(TARGET_SEGMENTS).map(([key, label]) => (
|
||||||
|
<option key={key} value={key}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userId}
|
||||||
|
onChange={e => setUserId(e.target.value)}
|
||||||
|
placeholder="Telegram ID или User ID"
|
||||||
|
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
{selectedTemplate && (
|
||||||
|
<div className="p-4 bg-dark-700/50 rounded-lg">
|
||||||
|
<h4 className="text-sm font-medium text-dark-300 mb-2">Предпросмотр</h4>
|
||||||
|
<div className="text-sm text-dark-200 whitespace-pre-wrap">
|
||||||
|
{selectedTemplate.message_text}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<span className="inline-block px-3 py-1.5 bg-accent-500 text-white text-sm rounded-lg">
|
||||||
|
{selectedTemplate.button_text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!isValid() || isLoading}
|
||||||
|
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<SendIcon />
|
||||||
|
{isLoading ? 'Отправка...' : 'Отправить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result Modal
|
||||||
|
interface ResultModalProps {
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
isSuccess: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultModal({ title, message, isSuccess, onClose }: ResultModalProps) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-dark-800 rounded-xl p-6 max-w-sm w-full text-center">
|
||||||
|
<div className={`w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center ${
|
||||||
|
isSuccess ? 'bg-emerald-500/20' : 'bg-error-500/20'
|
||||||
|
}`}>
|
||||||
|
{isSuccess ? (
|
||||||
|
<svg className="w-8 h-8 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-8 h-8 text-error-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-dark-100 mb-2">{title}</h3>
|
||||||
|
<p className="text-dark-400 mb-6">{message}</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-6 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors"
|
||||||
|
>
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPromoOffers() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'templates' | 'send' | 'logs'>('templates')
|
||||||
|
const [editingTemplate, setEditingTemplate] = useState<PromoOfferTemplate | null>(null)
|
||||||
|
const [showSendModal, setShowSendModal] = useState(false)
|
||||||
|
const [resultModal, setResultModal] = useState<{ title: string; message: string; isSuccess: boolean } | null>(null)
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
const { data: templatesData, isLoading: templatesLoading } = useQuery({
|
||||||
|
queryKey: ['admin-promo-templates'],
|
||||||
|
queryFn: promoOffersApi.getTemplates,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: logsData, isLoading: logsLoading } = useQuery({
|
||||||
|
queryKey: ['admin-promo-logs'],
|
||||||
|
queryFn: () => promoOffersApi.getLogs({ limit: 100 }),
|
||||||
|
enabled: activeTab === 'logs',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const updateTemplateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: PromoOfferTemplateUpdateRequest }) =>
|
||||||
|
promoOffersApi.updateTemplate(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-promo-templates'] })
|
||||||
|
setEditingTemplate(null)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const broadcastMutation = useMutation({
|
||||||
|
mutationFn: promoOffersApi.broadcastOffer,
|
||||||
|
onSuccess: (result) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] })
|
||||||
|
setShowSendModal(false)
|
||||||
|
setResultModal({
|
||||||
|
title: 'Отправлено!',
|
||||||
|
message: `Промопредложение отправлено ${result.created_offers} пользователям`,
|
||||||
|
isSuccess: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
setResultModal({
|
||||||
|
title: 'Ошибка',
|
||||||
|
message: error.response?.data?.detail || 'Не удалось отправить предложение',
|
||||||
|
isSuccess: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSendOffer = (templateId: number, target: string | null, userId: number | null) => {
|
||||||
|
const template = templatesData?.items.find(t => t.id === templateId)
|
||||||
|
if (!template) return
|
||||||
|
|
||||||
|
const data: any = {
|
||||||
|
notification_type: template.offer_type,
|
||||||
|
valid_hours: template.valid_hours,
|
||||||
|
discount_percent: template.discount_percent,
|
||||||
|
effect_type: template.offer_type === 'test_access' ? 'test_access' : 'percent_discount',
|
||||||
|
extra_data: {
|
||||||
|
template_id: template.id,
|
||||||
|
active_discount_hours: template.active_discount_hours,
|
||||||
|
test_duration_hours: template.test_duration_hours,
|
||||||
|
test_squad_uuids: template.test_squad_uuids,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
data.target = target
|
||||||
|
}
|
||||||
|
if (userId) {
|
||||||
|
data.telegram_id = userId
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastMutation.mutate(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const templates = templatesData?.items || []
|
||||||
|
const logs = logsData?.items || []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-orange-500/20 rounded-lg text-orange-400">
|
||||||
|
<GiftIcon />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-dark-100">Промопредложения</h1>
|
||||||
|
<p className="text-sm text-dark-400">Шаблоны, рассылка и логи предложений</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSendModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors"
|
||||||
|
>
|
||||||
|
<SendIcon />
|
||||||
|
Отправить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 mb-6 p-1 bg-dark-800 rounded-lg w-fit">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('templates')}
|
||||||
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
activeTab === 'templates'
|
||||||
|
? 'bg-dark-700 text-dark-100'
|
||||||
|
: 'text-dark-400 hover:text-dark-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Шаблоны ({templates.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('logs')}
|
||||||
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
activeTab === 'logs'
|
||||||
|
? 'bg-dark-700 text-dark-100'
|
||||||
|
: 'text-dark-400 hover:text-dark-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Логи
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Templates Tab */}
|
||||||
|
{activeTab === 'templates' && (
|
||||||
|
<>
|
||||||
|
{templatesLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-dark-400">Нет шаблонов</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{templates.map((template) => (
|
||||||
|
<div
|
||||||
|
key={template.id}
|
||||||
|
className={`p-4 bg-dark-800 rounded-xl border transition-colors ${
|
||||||
|
template.is_active ? 'border-dark-700' : 'border-dark-700/50 opacity-60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl">{getOfferTypeIcon(template.offer_type)}</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-dark-100">{template.name}</h3>
|
||||||
|
<span className="text-xs text-dark-500">{getOfferTypeLabel(template.offer_type)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingTemplate(template)}
|
||||||
|
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 hover:text-dark-100 transition-colors"
|
||||||
|
>
|
||||||
|
<EditIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
{template.discount_percent > 0 && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-dark-400">Скидка:</span>
|
||||||
|
<span className="text-accent-400 font-medium">{template.discount_percent}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-dark-400">Срок предложения:</span>
|
||||||
|
<span className="text-dark-200">{template.valid_hours}ч</span>
|
||||||
|
</div>
|
||||||
|
{template.active_discount_hours && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-dark-400">Действие скидки:</span>
|
||||||
|
<span className="text-dark-200">{template.active_discount_hours}ч</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{template.test_duration_hours && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-dark-400">Тестовый доступ:</span>
|
||||||
|
<span className="text-dark-200">{template.test_duration_hours}ч</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-t border-dark-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{template.is_active ? (
|
||||||
|
<span className="px-2 py-0.5 text-xs bg-emerald-500/20 text-emerald-400 rounded">
|
||||||
|
Активен
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-0.5 text-xs bg-dark-600 text-dark-400 rounded">
|
||||||
|
Неактивен
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Logs Tab */}
|
||||||
|
{activeTab === 'logs' && (
|
||||||
|
<>
|
||||||
|
{logsLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-dark-400">Нет записей</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{logs.map((log: PromoOfferLog) => (
|
||||||
|
<div
|
||||||
|
key={log.id}
|
||||||
|
className="p-4 bg-dark-800 rounded-xl border border-dark-700"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-dark-700 rounded-full flex items-center justify-center">
|
||||||
|
<UserIcon />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-medium text-dark-100">
|
||||||
|
{log.user?.full_name || log.user?.username || `User #${log.user_id}`}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-0.5 text-xs rounded ${getActionColor(log.action)}`}>
|
||||||
|
{getActionLabel(log.action)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-dark-400">
|
||||||
|
{log.source && (
|
||||||
|
<span>{getOfferTypeLabel(log.source)}</span>
|
||||||
|
)}
|
||||||
|
{log.percent && log.percent > 0 && (
|
||||||
|
<span className="ml-2 text-accent-400">{log.percent}%</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-dark-500">
|
||||||
|
<ClockIcon />
|
||||||
|
{formatDateTime(log.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Template Edit Modal */}
|
||||||
|
{editingTemplate && (
|
||||||
|
<TemplateEditModal
|
||||||
|
template={editingTemplate}
|
||||||
|
onSave={(data) => updateTemplateMutation.mutate({ id: editingTemplate.id, data })}
|
||||||
|
onClose={() => setEditingTemplate(null)}
|
||||||
|
isLoading={updateTemplateMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Send Offer Modal */}
|
||||||
|
{showSendModal && (
|
||||||
|
<SendOfferModal
|
||||||
|
templates={templates}
|
||||||
|
onSend={handleSendOffer}
|
||||||
|
onClose={() => setShowSendModal(false)}
|
||||||
|
isLoading={broadcastMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Result Modal */}
|
||||||
|
{resultModal && (
|
||||||
|
<ResultModal
|
||||||
|
title={resultModal.title}
|
||||||
|
message={resultModal.message}
|
||||||
|
isSuccess={resultModal.isSuccess}
|
||||||
|
onClose={() => setResultModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
1090
src/pages/AdminRemnawave.tsx
Normal file
1090
src/pages/AdminRemnawave.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ import { balanceApi } from '../api/balance'
|
|||||||
import { wheelApi } from '../api/wheel'
|
import { wheelApi } from '../api/wheel'
|
||||||
import ConnectionModal from '../components/ConnectionModal'
|
import ConnectionModal from '../components/ConnectionModal'
|
||||||
import Onboarding, { useOnboarding } from '../components/Onboarding'
|
import Onboarding, { useOnboarding } from '../components/Onboarding'
|
||||||
|
import PromoOffersSection from '../components/PromoOffersSection'
|
||||||
import { useCurrency } from '../hooks/useCurrency'
|
import { useCurrency } from '../hooks/useCurrency'
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
@@ -398,6 +399,9 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Promo Offers */}
|
||||||
|
<PromoOffersSection />
|
||||||
|
|
||||||
{/* Fortune Wheel Banner */}
|
{/* Fortune Wheel Banner */}
|
||||||
{wheelConfig?.is_enabled && (
|
{wheelConfig?.is_enabled && (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ const appSchemes = [
|
|||||||
|
|
||||||
const COUNTDOWN_SECONDS = 5
|
const COUNTDOWN_SECONDS = 5
|
||||||
|
|
||||||
|
// Validate deep link to prevent javascript: and other dangerous schemes
|
||||||
|
const isValidDeepLink = (url: string): boolean => {
|
||||||
|
if (!url) return false
|
||||||
|
const lowerUrl = url.toLowerCase().trim()
|
||||||
|
// Block dangerous schemes
|
||||||
|
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']
|
||||||
|
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Only allow known app schemes
|
||||||
|
return appSchemes.some(app => lowerUrl.startsWith(app.scheme))
|
||||||
|
}
|
||||||
|
|
||||||
export default function DeepLinkRedirect() {
|
export default function DeepLinkRedirect() {
|
||||||
const { i18n } = useTranslation()
|
const { i18n } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -104,13 +117,13 @@ export default function DeepLinkRedirect() {
|
|||||||
|
|
||||||
// Open deep link - same as miniapp, just window.location.href
|
// Open deep link - same as miniapp, just window.location.href
|
||||||
const openDeepLink = useCallback(() => {
|
const openDeepLink = useCallback(() => {
|
||||||
if (!deepLink) return
|
if (!deepLink || !isValidDeepLink(deepLink)) return
|
||||||
window.location.href = deepLink
|
window.location.href = deepLink
|
||||||
}, [deepLink])
|
}, [deepLink])
|
||||||
|
|
||||||
// Countdown timer effect
|
// Countdown timer effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!deepLink) {
|
if (!deepLink || !isValidDeepLink(deepLink)) {
|
||||||
setStatus('error')
|
setStatus('error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
import { infoApi, FaqPage } from '../api/info'
|
import { infoApi, FaqPage } from '../api/info'
|
||||||
|
|
||||||
const InfoIcon = () => (
|
const InfoIcon = () => (
|
||||||
@@ -41,6 +42,15 @@ const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
|
|||||||
|
|
||||||
type TabType = 'faq' | 'rules' | 'privacy' | 'offer'
|
type TabType = 'faq' | 'rules' | 'privacy' | 'offer'
|
||||||
|
|
||||||
|
// Sanitize HTML content to prevent XSS
|
||||||
|
const sanitizeHtml = (html: string): string => {
|
||||||
|
return DOMPurify.sanitize(html, {
|
||||||
|
ALLOWED_TAGS: ['p', 'br', 'b', 'i', 'u', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre', 'span', 'div'],
|
||||||
|
ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Convert plain text to HTML with proper formatting
|
// Convert plain text to HTML with proper formatting
|
||||||
const formatContent = (content: string): string => {
|
const formatContent = (content: string): string => {
|
||||||
if (!content) return ''
|
if (!content) return ''
|
||||||
@@ -49,11 +59,11 @@ const formatContent = (content: string): string => {
|
|||||||
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(content)
|
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(content)
|
||||||
|
|
||||||
if (hasHtmlTags) {
|
if (hasHtmlTags) {
|
||||||
return content
|
return sanitizeHtml(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert plain text to formatted HTML
|
// Convert plain text to formatted HTML
|
||||||
return content
|
const result = content
|
||||||
.split(/\n\n+/) // Split by double newlines (paragraphs)
|
.split(/\n\n+/) // Split by double newlines (paragraphs)
|
||||||
.map(paragraph => {
|
.map(paragraph => {
|
||||||
// Check if it's a header (starts with # or numeric like "1.")
|
// Check if it's a header (starts with # or numeric like "1.")
|
||||||
@@ -83,6 +93,8 @@ const formatContent = (content: string): string => {
|
|||||||
return `<p>${formattedParagraph}</p>`
|
return `<p>${formattedParagraph}</p>`
|
||||||
})
|
})
|
||||||
.join('')
|
.join('')
|
||||||
|
|
||||||
|
return sanitizeHtml(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Info() {
|
export default function Info() {
|
||||||
|
|||||||
@@ -2,247 +2,341 @@ import { useState } from 'react'
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { referralApi } from '../api/referral'
|
import { referralApi } from '../api/referral'
|
||||||
|
import { brandingApi } from '../api/branding'
|
||||||
import { useCurrency } from '../hooks/useCurrency'
|
import { useCurrency } from '../hooks/useCurrency'
|
||||||
|
|
||||||
const CopyIcon = () => (
|
const CopyIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg
|
||||||
<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" />
|
className='w-4 h-4'
|
||||||
</svg>
|
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 = () => (
|
const CheckIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
className='w-4 h-4'
|
||||||
</svg>
|
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 = () => (
|
const ShareIcon = () => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<svg
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M7 8l5-5m0 0l5 5m-5-5v12" />
|
className='w-4 h-4'
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 15v3a2 2 0 002 2h12a2 2 0 002-2v-3" />
|
fill='none'
|
||||||
</svg>
|
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() {
|
export default function Referral() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { formatAmount, currencySymbol, formatPositive } = useCurrency()
|
const { formatAmount, currencySymbol, formatPositive } = useCurrency()
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
const { data: info, isLoading } = useQuery({
|
const { data: info, isLoading } = useQuery({
|
||||||
queryKey: ['referral-info'],
|
queryKey: ['referral-info'],
|
||||||
queryFn: referralApi.getReferralInfo,
|
queryFn: referralApi.getReferralInfo,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: terms } = useQuery({
|
const { data: terms } = useQuery({
|
||||||
queryKey: ['referral-terms'],
|
queryKey: ['referral-terms'],
|
||||||
queryFn: referralApi.getReferralTerms,
|
queryFn: referralApi.getReferralTerms,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: referralList } = useQuery({
|
const { data: referralList } = useQuery({
|
||||||
queryKey: ['referral-list'],
|
queryKey: ['referral-list'],
|
||||||
queryFn: () => referralApi.getReferralList({ per_page: 10 }),
|
queryFn: () => referralApi.getReferralList({ per_page: 10 }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: earnings } = useQuery({
|
const { data: earnings } = useQuery({
|
||||||
queryKey: ['referral-earnings'],
|
queryKey: ['referral-earnings'],
|
||||||
queryFn: () => referralApi.getReferralEarnings({ per_page: 10 }),
|
queryFn: () => referralApi.getReferralEarnings({ per_page: 10 }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const copyLink = () => {
|
const { data: branding } = useQuery({
|
||||||
if (info?.referral_link) {
|
queryKey: ['branding'],
|
||||||
navigator.clipboard.writeText(info.referral_link)
|
queryFn: brandingApi.getBranding,
|
||||||
setCopied(true)
|
staleTime: 60000,
|
||||||
setTimeout(() => setCopied(false), 2000)
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const shareLink = () => {
|
const copyLink = () => {
|
||||||
if (!info?.referral_link) return
|
if (info?.referral_link) {
|
||||||
const shareText = t('referral.shareMessage', {
|
navigator.clipboard.writeText(info.referral_link)
|
||||||
percent: info?.commission_percent || 0,
|
setCopied(true)
|
||||||
})
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (navigator.share) {
|
const shareLink = () => {
|
||||||
navigator
|
if (!info?.referral_link) return
|
||||||
.share({
|
const shareText = t('referral.shareMessage', {
|
||||||
title: t('referral.title'),
|
percent: info?.commission_percent || 0,
|
||||||
text: shareText,
|
botName: branding?.name || import.meta.env.VITE_APP_NAME || 'Cabinet',
|
||||||
url: info.referral_link,
|
})
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// ignore cancellation errors
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(
|
if (navigator.share) {
|
||||||
info.referral_link
|
navigator
|
||||||
)}&text=${encodeURIComponent(shareText)}`
|
.share({
|
||||||
window.open(telegramUrl, '_blank', 'noopener,noreferrer')
|
title: t('referral.title'),
|
||||||
}
|
text: shareText,
|
||||||
|
url: info.referral_link,
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// ignore cancellation errors
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
const telegramUrl = `https://t.me/share/url?url=${encodeURIComponent(
|
||||||
return (
|
info.referral_link
|
||||||
<div className="flex items-center justify-center min-h-64">
|
)}&text=${encodeURIComponent(shareText)}`
|
||||||
<div className="w-10 h-10 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
window.open(telegramUrl, '_blank', 'noopener,noreferrer')
|
||||||
</div>
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
if (isLoading) {
|
||||||
<div className="space-y-6">
|
return (
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('referral.title')}</h1>
|
<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 */}
|
return (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
<div className='space-y-6'>
|
||||||
<div className="card">
|
<h1 className='text-2xl sm:text-3xl font-bold text-dark-50'>
|
||||||
<div className="text-sm text-dark-400">{t('referral.stats.totalReferrals')}</div>
|
{t('referral.title')}
|
||||||
<div className="stat-value mt-1">{info?.total_referrals || 0}</div>
|
</h1>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Referral Link */}
|
{/* Stats Cards */}
|
||||||
<div className="card">
|
<div className='grid grid-cols-2 sm:grid-cols-3 gap-4'>
|
||||||
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.yourLink')}</h2>
|
<div className='card'>
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className='text-sm text-dark-400'>
|
||||||
<input
|
{t('referral.stats.totalReferrals')}
|
||||||
type="text"
|
</div>
|
||||||
readOnly
|
<div className='stat-value mt-1'>{info?.total_referrals || 0}</div>
|
||||||
value={info?.referral_link || ''}
|
<div className='text-sm text-dark-500 mt-1'>
|
||||||
className="input flex-1"
|
{info?.active_referrals || 0}{' '}
|
||||||
/>
|
{t('referral.stats.activeReferrals').toLowerCase()}
|
||||||
<div className="flex gap-2">
|
</div>
|
||||||
<button
|
</div>
|
||||||
onClick={copyLink}
|
<div className='card'>
|
||||||
disabled={!info?.referral_link}
|
<div className='text-sm text-dark-400'>
|
||||||
className={`btn-primary px-5 ${
|
{t('referral.stats.totalEarnings')}
|
||||||
copied ? 'bg-success-500 hover:bg-success-500' : ''
|
</div>
|
||||||
} ${!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''}`}
|
<div className='stat-value text-success-400 mt-1'>
|
||||||
>
|
{formatPositive(info?.total_earnings_rubles || 0)}
|
||||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
</div>
|
||||||
<span className="ml-2">{copied ? t('referral.copied') : t('referral.copyLink')}</span>
|
</div>
|
||||||
</button>
|
<div className='card col-span-2 sm:col-span-1'>
|
||||||
<button
|
<div className='text-sm text-dark-400'>
|
||||||
onClick={shareLink}
|
{t('referral.stats.commissionRate')}
|
||||||
disabled={!info?.referral_link}
|
</div>
|
||||||
className={`btn-secondary px-5 flex items-center ${
|
<div className='stat-value text-accent-400 mt-1'>
|
||||||
!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''
|
{info?.commission_percent || 0}%
|
||||||
}`}
|
</div>
|
||||||
>
|
</div>
|
||||||
<ShareIcon />
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Program Terms */}
|
{/* Referral Link */}
|
||||||
{terms && (
|
<div className='card'>
|
||||||
<div className="card">
|
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
|
||||||
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.terms.title')}</h2>
|
{t('referral.yourLink')}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
</h2>
|
||||||
<div className="p-3 rounded-xl bg-dark-800/30">
|
<div className='flex flex-col gap-2 sm:flex-row'>
|
||||||
<div className="text-sm text-dark-500">{t('referral.terms.commission')}</div>
|
<input
|
||||||
<div className="text-lg font-semibold text-dark-100 mt-1">{terms.commission_percent}%</div>
|
type='text'
|
||||||
</div>
|
readOnly
|
||||||
<div className="p-3 rounded-xl bg-dark-800/30">
|
value={info?.referral_link || ''}
|
||||||
<div className="text-sm text-dark-500">{t('referral.terms.minTopup')}</div>
|
className='input flex-1'
|
||||||
<div className="text-lg font-semibold text-dark-100 mt-1">{formatAmount(terms.minimum_topup_rubles)} {currencySymbol}</div>
|
/>
|
||||||
</div>
|
<div className='flex gap-2'>
|
||||||
<div className="p-3 rounded-xl bg-dark-800/30">
|
<button
|
||||||
<div className="text-sm text-dark-500">{t('referral.terms.newUserBonus')}</div>
|
onClick={copyLink}
|
||||||
<div className="text-lg font-semibold text-success-400 mt-1">{formatPositive(terms.first_topup_bonus_rubles)}</div>
|
disabled={!info?.referral_link}
|
||||||
</div>
|
className={`btn-primary px-5 ${
|
||||||
<div className="p-3 rounded-xl bg-dark-800/30">
|
copied ? 'bg-success-500 hover:bg-success-500' : ''
|
||||||
<div className="text-sm text-dark-500">{t('referral.terms.inviterBonus')}</div>
|
} ${!info?.referral_link ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
<div className="text-lg font-semibold text-success-400 mt-1">{formatPositive(terms.inviter_bonus_rubles)}</div>
|
>
|
||||||
</div>
|
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||||
</div>
|
<span className='ml-2'>
|
||||||
</div>
|
{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 */}
|
{/* Program Terms */}
|
||||||
<div className="card">
|
{terms && (
|
||||||
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.yourReferrals')}</h2>
|
<div className='card'>
|
||||||
{referralList?.items && referralList.items.length > 0 ? (
|
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
|
||||||
<div className="space-y-3">
|
{t('referral.terms.title')}
|
||||||
{referralList.items.map((ref) => (
|
</h2>
|
||||||
<div
|
<div className='grid grid-cols-2 md:grid-cols-4 gap-4'>
|
||||||
key={ref.id}
|
<div className='p-3 rounded-xl bg-dark-800/30'>
|
||||||
className="flex items-center justify-between p-3 rounded-xl bg-dark-800/30 border border-dark-700/30"
|
<div className='text-sm text-dark-500'>
|
||||||
>
|
{t('referral.terms.commission')}
|
||||||
<div>
|
</div>
|
||||||
<div className="text-dark-100 font-medium">
|
<div className='text-lg font-semibold text-dark-100 mt-1'>
|
||||||
{ref.first_name || ref.username || `User #${ref.id}`}
|
{terms.commission_percent}%
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-dark-500 mt-0.5">
|
</div>
|
||||||
{new Date(ref.created_at).toLocaleDateString()}
|
<div className='p-3 rounded-xl bg-dark-800/30'>
|
||||||
</div>
|
<div className='text-sm text-dark-500'>
|
||||||
</div>
|
{t('referral.terms.minTopup')}
|
||||||
{ref.has_paid ? (
|
</div>
|
||||||
<span className="badge-success">{t('referral.status.paid')}</span>
|
<div className='text-lg font-semibold text-dark-100 mt-1'>
|
||||||
) : (
|
{formatAmount(terms.minimum_topup_rubles)} {currencySymbol}
|
||||||
<span className="badge-neutral">{t('referral.status.pending')}</span>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
<div className='p-3 rounded-xl bg-dark-800/30'>
|
||||||
))}
|
<div className='text-sm text-dark-500'>
|
||||||
</div>
|
{t('referral.terms.newUserBonus')}
|
||||||
) : (
|
</div>
|
||||||
<div className="text-center py-12">
|
<div className='text-lg font-semibold text-success-400 mt-1'>
|
||||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
|
{formatPositive(terms.first_topup_bonus_rubles)}
|
||||||
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
</div>
|
||||||
<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" />
|
</div>
|
||||||
</svg>
|
<div className='p-3 rounded-xl bg-dark-800/30'>
|
||||||
</div>
|
<div className='text-sm text-dark-500'>
|
||||||
<div className="text-dark-400">{t('referral.noReferrals')}</div>
|
{t('referral.terms.inviterBonus')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className='text-lg font-semibold text-success-400 mt-1'>
|
||||||
</div>
|
{formatPositive(terms.inviter_bonus_rubles)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Earnings History */}
|
{/* Referrals List */}
|
||||||
{earnings?.items && earnings.items.length > 0 && (
|
<div className='card'>
|
||||||
<div className="card">
|
<h2 className='text-lg font-semibold text-dark-100 mb-4'>
|
||||||
<h2 className="text-lg font-semibold text-dark-100 mb-4">{t('referral.earningsHistory')}</h2>
|
{t('referral.yourReferrals')}
|
||||||
<div className="space-y-3">
|
</h2>
|
||||||
{earnings.items.map((earning) => (
|
{referralList?.items && referralList.items.length > 0 ? (
|
||||||
<div
|
<div className='space-y-3'>
|
||||||
key={earning.id}
|
{referralList.items.map(ref => (
|
||||||
className="flex items-center justify-between p-3 rounded-xl bg-dark-800/30 border border-dark-700/30"
|
<div
|
||||||
>
|
key={ref.id}
|
||||||
<div>
|
className='flex items-center justify-between p-3 rounded-xl bg-dark-800/30 border border-dark-700/30'
|
||||||
<div className="text-dark-100">
|
>
|
||||||
{earning.referral_first_name || earning.referral_username || 'Referral'}
|
<div>
|
||||||
</div>
|
<div className='text-dark-100 font-medium'>
|
||||||
<div className="text-xs text-dark-500 mt-0.5">
|
{ref.first_name || ref.username || `User #${ref.id}`}
|
||||||
{t(`referral.reasons.${earning.reason}`, earning.reason)} • {new Date(earning.created_at).toLocaleDateString()}
|
</div>
|
||||||
</div>
|
<div className='text-xs text-dark-500 mt-0.5'>
|
||||||
</div>
|
{new Date(ref.created_at).toLocaleDateString()}
|
||||||
<div className="text-success-400 font-semibold">
|
</div>
|
||||||
{formatPositive(earning.amount_rubles)}
|
</div>
|
||||||
</div>
|
{ref.has_paid ? (
|
||||||
</div>
|
<span className='badge-success'>
|
||||||
))}
|
{t('referral.status.paid')}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<span className='badge-neutral'>
|
||||||
</div>
|
{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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { AxiosError } from 'axios'
|
import { AxiosError } from 'axios'
|
||||||
import { subscriptionApi } from '../api/subscription'
|
import { subscriptionApi } from '../api/subscription'
|
||||||
|
import { promoApi } from '../api/promo'
|
||||||
import type { PurchaseSelection, PeriodOption, Tariff, TariffPeriod, ClassicPurchaseOptions } from '../types'
|
import type { PurchaseSelection, PeriodOption, Tariff, TariffPeriod, ClassicPurchaseOptions } from '../types'
|
||||||
import ConnectionModal from '../components/ConnectionModal'
|
import ConnectionModal from '../components/ConnectionModal'
|
||||||
import { useCurrency } from '../hooks/useCurrency'
|
import { useCurrency } from '../hooks/useCurrency'
|
||||||
@@ -55,6 +56,24 @@ export default function Subscription() {
|
|||||||
// Helper to format price from kopeks
|
// Helper to format price from kopeks
|
||||||
const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`
|
const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`
|
||||||
|
|
||||||
|
// Helper to apply promo discount to a price
|
||||||
|
const applyPromoDiscount = (priceKopeks: number, hasExistingDiscount: boolean = false): {
|
||||||
|
price: number;
|
||||||
|
original: number | null;
|
||||||
|
percent: number | null
|
||||||
|
} => {
|
||||||
|
// Only apply promo discount if no existing discount (from promo group) and we have an active promo discount
|
||||||
|
if (!activeDiscount?.is_active || !activeDiscount.discount_percent || hasExistingDiscount) {
|
||||||
|
return { price: priceKopeks, original: null, percent: null }
|
||||||
|
}
|
||||||
|
const discountedPrice = Math.round(priceKopeks * (1 - activeDiscount.discount_percent / 100))
|
||||||
|
return {
|
||||||
|
price: discountedPrice,
|
||||||
|
original: priceKopeks,
|
||||||
|
percent: activeDiscount.discount_percent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Purchase state (classic mode)
|
// Purchase state (classic mode)
|
||||||
const [currentStep, setCurrentStep] = useState<PurchaseStep>('period')
|
const [currentStep, setCurrentStep] = useState<PurchaseStep>('period')
|
||||||
const [selectedPeriod, setSelectedPeriod] = useState<PeriodOption | null>(null)
|
const [selectedPeriod, setSelectedPeriod] = useState<PeriodOption | null>(null)
|
||||||
@@ -91,6 +110,13 @@ export default function Subscription() {
|
|||||||
queryFn: subscriptionApi.getPurchaseOptions,
|
queryFn: subscriptionApi.getPurchaseOptions,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Fetch active promo discount
|
||||||
|
const { data: activeDiscount } = useQuery({
|
||||||
|
queryKey: ['active-discount'],
|
||||||
|
queryFn: promoApi.getActiveDiscount,
|
||||||
|
staleTime: 30000,
|
||||||
|
})
|
||||||
|
|
||||||
// Check if in tariffs mode (moved up to be available for useEffect)
|
// Check if in tariffs mode (moved up to be available for useEffect)
|
||||||
const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs'
|
const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs'
|
||||||
const classicOptions = !isTariffsMode ? purchaseOptions as ClassicPurchaseOptions : null
|
const classicOptions = !isTariffsMode ? purchaseOptions as ClassicPurchaseOptions : null
|
||||||
@@ -275,18 +301,20 @@ export default function Subscription() {
|
|||||||
// Auto-scroll to switch tariff modal when it appears
|
// Auto-scroll to switch tariff modal when it appears
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (switchTariffId && switchModalRef.current) {
|
if (switchTariffId && switchModalRef.current) {
|
||||||
setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
switchModalRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
switchModalRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
}, 100)
|
}, 100)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [switchTariffId])
|
}, [switchTariffId])
|
||||||
|
|
||||||
// Auto-scroll to tariff purchase form when it appears
|
// Auto-scroll to tariff purchase form when it appears
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showTariffPurchase && tariffPurchaseRef.current) {
|
if (showTariffPurchase && tariffPurchaseRef.current) {
|
||||||
setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
tariffPurchaseRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
tariffPurchaseRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
}, 100)
|
}, 100)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [showTariffPurchase])
|
}, [showTariffPurchase])
|
||||||
|
|
||||||
@@ -294,11 +322,12 @@ export default function Subscription() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const state = location.state as { scrollToExtend?: boolean } | null
|
const state = location.state as { scrollToExtend?: boolean } | null
|
||||||
if (state?.scrollToExtend && tariffsCardRef.current) {
|
if (state?.scrollToExtend && tariffsCardRef.current) {
|
||||||
setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
tariffsCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
tariffsCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
}, 300)
|
}, 300)
|
||||||
// Clear the state to prevent re-scrolling on subsequent renders
|
// Clear the state to prevent re-scrolling on subsequent renders
|
||||||
window.history.replaceState({}, document.title)
|
window.history.replaceState({}, document.title)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}, [location.state])
|
}, [location.state])
|
||||||
|
|
||||||
@@ -1116,16 +1145,25 @@ export default function Subscription() {
|
|||||||
// Daily tariff price (is_daily + daily_price_kopeks)
|
// Daily tariff price (is_daily + daily_price_kopeks)
|
||||||
const dailyPrice = tariff.daily_price_kopeks ?? tariff.price_per_day_kopeks ?? 0
|
const dailyPrice = tariff.daily_price_kopeks ?? tariff.price_per_day_kopeks ?? 0
|
||||||
const originalDailyPrice = tariff.original_daily_price_kopeks || 0
|
const originalDailyPrice = tariff.original_daily_price_kopeks || 0
|
||||||
|
const hasExistingDailyDiscount = originalDailyPrice > dailyPrice
|
||||||
if (dailyPrice > 0) {
|
if (dailyPrice > 0) {
|
||||||
|
// Apply promo discount if no existing discount
|
||||||
|
const promoDaily = applyPromoDiscount(dailyPrice, hasExistingDailyDiscount)
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="text-accent-400 font-medium">{formatPrice(dailyPrice)}</span>
|
<span className="text-accent-400 font-medium">{formatPrice(promoDaily.price)}</span>
|
||||||
{originalDailyPrice > dailyPrice && (
|
{/* Show original price from promo group or promo offer */}
|
||||||
<span className="text-dark-500 text-xs line-through">{formatPrice(originalDailyPrice)}</span>
|
{(hasExistingDailyDiscount || promoDaily.original) && (
|
||||||
|
<span className="text-dark-500 text-xs line-through">
|
||||||
|
{formatPrice(hasExistingDailyDiscount ? originalDailyPrice : promoDaily.original!)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<span>/ день</span>
|
<span>/ день</span>
|
||||||
{tariff.daily_discount_percent && tariff.daily_discount_percent > 0 && (
|
{/* Show discount badge */}
|
||||||
|
{(tariff.daily_discount_percent && tariff.daily_discount_percent > 0) ? (
|
||||||
<span className="px-1.5 py-0.5 bg-success-500/20 text-success-400 text-xs rounded">-{tariff.daily_discount_percent}%</span>
|
<span className="px-1.5 py-0.5 bg-success-500/20 text-success-400 text-xs rounded">-{tariff.daily_discount_percent}%</span>
|
||||||
|
) : promoDaily.percent && (
|
||||||
|
<span className="px-1.5 py-0.5 bg-orange-500/20 text-orange-400 text-xs rounded">-{promoDaily.percent}%</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -1133,18 +1171,24 @@ export default function Subscription() {
|
|||||||
// Period-based price
|
// Period-based price
|
||||||
if (tariff.periods.length > 0) {
|
if (tariff.periods.length > 0) {
|
||||||
const firstPeriod = tariff.periods[0]
|
const firstPeriod = tariff.periods[0]
|
||||||
const hasDiscount = firstPeriod?.original_price_kopeks && firstPeriod.original_price_kopeks > firstPeriod.price_kopeks
|
const hasExistingDiscount = !!(firstPeriod?.original_price_kopeks && firstPeriod.original_price_kopeks > firstPeriod.price_kopeks)
|
||||||
|
// Apply promo discount if no existing discount
|
||||||
|
const promoPeriod = applyPromoDiscount(firstPeriod?.price_kopeks || 0, hasExistingDiscount)
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-2 flex-wrap">
|
<span className="flex items-center gap-2 flex-wrap">
|
||||||
<span>{t('subscription.from')}</span>
|
<span>{t('subscription.from')}</span>
|
||||||
<span className="text-accent-400 font-medium">{formatPrice(firstPeriod?.price_kopeks || 0)}</span>
|
<span className="text-accent-400 font-medium">{formatPrice(promoPeriod.price)}</span>
|
||||||
{hasDiscount && (
|
{/* Show original price from promo group or promo offer */}
|
||||||
<>
|
{(hasExistingDiscount || promoPeriod.original) && (
|
||||||
<span className="text-dark-500 text-xs line-through">{formatPrice(firstPeriod.original_price_kopeks!)}</span>
|
<span className="text-dark-500 text-xs line-through">
|
||||||
{firstPeriod.discount_percent && (
|
{formatPrice(hasExistingDiscount ? firstPeriod.original_price_kopeks! : promoPeriod.original!)}
|
||||||
<span className="px-1.5 py-0.5 bg-success-500/20 text-success-400 text-xs rounded">-{firstPeriod.discount_percent}%</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</>
|
{/* Show discount badge */}
|
||||||
|
{hasExistingDiscount && firstPeriod.discount_percent ? (
|
||||||
|
<span className="px-1.5 py-0.5 bg-success-500/20 text-success-400 text-xs rounded">-{firstPeriod.discount_percent}%</span>
|
||||||
|
) : promoPeriod.percent && (
|
||||||
|
<span className="px-1.5 py-0.5 bg-orange-500/20 text-orange-400 text-xs rounded">-{promoPeriod.percent}%</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -1330,34 +1374,47 @@ export default function Subscription() {
|
|||||||
{/* Fixed periods */}
|
{/* Fixed periods */}
|
||||||
{selectedTariff.periods.length > 0 && !useCustomDays && (
|
{selectedTariff.periods.length > 0 && !useCustomDays && (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-4">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 mb-4">
|
||||||
{selectedTariff.periods.map((period) => (
|
{selectedTariff.periods.map((period) => {
|
||||||
<button
|
const hasExistingDiscount = !!(period.original_price_kopeks && period.original_price_kopeks > period.price_kopeks)
|
||||||
key={period.days}
|
const promoPeriod = applyPromoDiscount(period.price_kopeks, hasExistingDiscount)
|
||||||
onClick={() => {
|
const displayDiscount = hasExistingDiscount ? period.discount_percent : promoPeriod.percent
|
||||||
setSelectedTariffPeriod(period)
|
const displayOriginal = hasExistingDiscount ? period.original_price_kopeks : promoPeriod.original
|
||||||
setUseCustomDays(false)
|
const displayPrice = promoPeriod.price
|
||||||
}}
|
const displayPerMonth = hasExistingDiscount
|
||||||
className={`p-4 rounded-xl border text-left transition-all relative ${
|
? period.price_per_month_kopeks
|
||||||
selectedTariffPeriod?.days === period.days && !useCustomDays
|
: Math.round(promoPeriod.price / (period.days / 30))
|
||||||
? 'border-accent-500 bg-accent-500/10'
|
|
||||||
: 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
|
return (
|
||||||
}`}
|
<button
|
||||||
>
|
key={period.days}
|
||||||
{period.discount_percent && period.discount_percent > 0 && (
|
onClick={() => {
|
||||||
<div className="absolute -top-2 -right-2 px-2 py-0.5 bg-success-500 text-white text-xs font-medium rounded-full">
|
setSelectedTariffPeriod(period)
|
||||||
-{period.discount_percent}%
|
setUseCustomDays(false)
|
||||||
</div>
|
}}
|
||||||
)}
|
className={`p-4 rounded-xl border text-left transition-all relative ${
|
||||||
<div className="text-lg font-semibold text-dark-100">{period.label}</div>
|
selectedTariffPeriod?.days === period.days && !useCustomDays
|
||||||
<div className="flex items-center gap-2">
|
? 'border-accent-500 bg-accent-500/10'
|
||||||
<span className="text-accent-400 font-medium">{formatPrice(period.price_kopeks)}</span>
|
: 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
|
||||||
{period.original_price_kopeks && period.original_price_kopeks > period.price_kopeks && (
|
}`}
|
||||||
<span className="text-dark-500 text-sm line-through">{formatPrice(period.original_price_kopeks)}</span>
|
>
|
||||||
|
{displayDiscount && displayDiscount > 0 && (
|
||||||
|
<div className={`absolute -top-2 -right-2 px-2 py-0.5 text-white text-xs font-medium rounded-full ${
|
||||||
|
hasExistingDiscount ? 'bg-success-500' : 'bg-orange-500'
|
||||||
|
}`}>
|
||||||
|
-{displayDiscount}%
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
<div className="text-lg font-semibold text-dark-100">{period.label}</div>
|
||||||
<div className="text-xs text-dark-500 mt-1">{formatPrice(period.price_per_month_kopeks)}/{t('subscription.month')}</div>
|
<div className="flex items-center gap-2">
|
||||||
</button>
|
<span className="text-accent-400 font-medium">{formatPrice(displayPrice)}</span>
|
||||||
))}
|
{displayOriginal && displayOriginal > displayPrice && (
|
||||||
|
<span className="text-dark-500 text-sm line-through">{formatPrice(displayOriginal)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-dark-500 mt-1">{formatPrice(displayPerMonth)}/{t('subscription.month')}</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1400,10 +1457,25 @@ export default function Subscription() {
|
|||||||
className="w-20 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 text-center"
|
className="w-20 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 text-center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
{(() => {
|
||||||
<span className="text-dark-400">{customDays} дней × {formatPrice(selectedTariff.price_per_day_kopeks ?? 0)}/день</span>
|
const basePrice = customDays * (selectedTariff.price_per_day_kopeks ?? 0)
|
||||||
<span className="text-accent-400 font-medium">{formatPrice(customDays * (selectedTariff.price_per_day_kopeks ?? 0))}</span>
|
const hasExistingDiscount = !!(selectedTariff.original_price_per_day_kopeks && selectedTariff.original_price_per_day_kopeks > (selectedTariff.price_per_day_kopeks ?? 0))
|
||||||
</div>
|
const promoCustom = applyPromoDiscount(basePrice, hasExistingDiscount)
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-dark-400">{customDays} дней × {formatPrice(selectedTariff.price_per_day_kopeks ?? 0)}/день</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-accent-400 font-medium">{formatPrice(promoCustom.price)}</span>
|
||||||
|
{promoCustom.original && (
|
||||||
|
<>
|
||||||
|
<span className="text-dark-500 text-xs line-through">{formatPrice(promoCustom.original)}</span>
|
||||||
|
<span className="px-1.5 py-0.5 bg-orange-500/20 text-orange-400 text-xs rounded">-{promoCustom.percent}%</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1472,42 +1544,76 @@ export default function Subscription() {
|
|||||||
{/* Summary & Purchase */}
|
{/* Summary & Purchase */}
|
||||||
{(selectedTariffPeriod || useCustomDays) && (
|
{(selectedTariffPeriod || useCustomDays) && (
|
||||||
<div className="bg-dark-800/50 rounded-xl p-5">
|
<div className="bg-dark-800/50 rounded-xl p-5">
|
||||||
{/* Price breakdown */}
|
|
||||||
<div className="space-y-2 mb-4">
|
|
||||||
{useCustomDays ? (
|
|
||||||
<div className="flex justify-between text-sm text-dark-300">
|
|
||||||
<span>Период: {customDays} дней</span>
|
|
||||||
<span>{formatPrice(customDays * (selectedTariff.price_per_day_kopeks ?? 0))}</span>
|
|
||||||
</div>
|
|
||||||
) : selectedTariffPeriod && (
|
|
||||||
<div className="flex justify-between text-sm text-dark-300">
|
|
||||||
<span>Период: {selectedTariffPeriod.label}</span>
|
|
||||||
<span>{formatPrice(selectedTariffPeriod.price_kopeks)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{useCustomTraffic && selectedTariff.custom_traffic_enabled && (
|
|
||||||
<div className="flex justify-between text-sm text-dark-300">
|
|
||||||
<span>Трафик: {customTrafficGb} ГБ</span>
|
|
||||||
<span>+{formatPrice(customTrafficGb * (selectedTariff.traffic_price_per_gb_kopeks ?? 0))}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const periodPrice = useCustomDays
|
// Calculate prices with promo discount
|
||||||
|
const basePeriodPrice = useCustomDays
|
||||||
? customDays * (selectedTariff.price_per_day_kopeks ?? 0)
|
? customDays * (selectedTariff.price_per_day_kopeks ?? 0)
|
||||||
: (selectedTariffPeriod?.price_kopeks || 0)
|
: (selectedTariffPeriod?.price_kopeks || 0)
|
||||||
|
const hasExistingPeriodDiscount = !useCustomDays && selectedTariffPeriod?.original_price_kopeks
|
||||||
|
? selectedTariffPeriod.original_price_kopeks > selectedTariffPeriod.price_kopeks
|
||||||
|
: false
|
||||||
|
const promoPeriod = applyPromoDiscount(basePeriodPrice, hasExistingPeriodDiscount)
|
||||||
|
|
||||||
const trafficPrice = useCustomTraffic && selectedTariff.custom_traffic_enabled
|
const trafficPrice = useCustomTraffic && selectedTariff.custom_traffic_enabled
|
||||||
? customTrafficGb * (selectedTariff.traffic_price_per_gb_kopeks ?? 0)
|
? customTrafficGb * (selectedTariff.traffic_price_per_gb_kopeks ?? 0)
|
||||||
: 0
|
: 0
|
||||||
const totalPrice = periodPrice + trafficPrice
|
|
||||||
|
const totalPrice = promoPeriod.price + trafficPrice
|
||||||
|
const originalTotal = promoPeriod.original ? promoPeriod.original + trafficPrice : null
|
||||||
const hasEnoughBalance = purchaseOptions && totalPrice <= purchaseOptions.balance_kopeks
|
const hasEnoughBalance = purchaseOptions && totalPrice <= purchaseOptions.balance_kopeks
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Price breakdown */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
{useCustomDays ? (
|
||||||
|
<div className="flex justify-between text-sm text-dark-300">
|
||||||
|
<span>Период: {customDays} дней</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{formatPrice(promoPeriod.price)}</span>
|
||||||
|
{promoPeriod.original && (
|
||||||
|
<span className="text-dark-500 text-xs line-through">{formatPrice(promoPeriod.original)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : selectedTariffPeriod && (
|
||||||
|
<div className="flex justify-between text-sm text-dark-300">
|
||||||
|
<span>Период: {selectedTariffPeriod.label}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{formatPrice(promoPeriod.price)}</span>
|
||||||
|
{(hasExistingPeriodDiscount || promoPeriod.original) && (
|
||||||
|
<span className="text-dark-500 text-xs line-through">
|
||||||
|
{formatPrice(hasExistingPeriodDiscount ? selectedTariffPeriod.original_price_kopeks! : promoPeriod.original!)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{useCustomTraffic && selectedTariff.custom_traffic_enabled && (
|
||||||
|
<div className="flex justify-between text-sm text-dark-300">
|
||||||
|
<span>Трафик: {customTrafficGb} ГБ</span>
|
||||||
|
<span>+{formatPrice(trafficPrice)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Promo discount info */}
|
||||||
|
{promoPeriod.percent && (
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-4 p-2 bg-orange-500/10 border border-orange-500/30 rounded-lg">
|
||||||
|
<span className="text-orange-400 text-sm font-medium">
|
||||||
|
{t('promo.discountApplied')} -{promoPeriod.percent}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-between items-center mb-4 pt-2 border-t border-dark-700/50">
|
<div className="flex justify-between items-center mb-4 pt-2 border-t border-dark-700/50">
|
||||||
<span className="text-dark-100 font-medium">{t('subscription.total')}</span>
|
<span className="text-dark-100 font-medium">{t('subscription.total')}</span>
|
||||||
<span className="text-2xl font-bold text-accent-400">{formatPrice(totalPrice)}</span>
|
<div className="text-right">
|
||||||
|
<span className="text-2xl font-bold text-accent-400">{formatPrice(totalPrice)}</span>
|
||||||
|
{originalTotal && (
|
||||||
|
<div className="text-sm text-dark-500 line-through">{formatPrice(originalTotal)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{purchaseOptions && !hasEnoughBalance && (
|
{purchaseOptions && !hasEnoughBalance && (
|
||||||
@@ -1593,93 +1699,139 @@ export default function Subscription() {
|
|||||||
{/* Step: Period Selection */}
|
{/* Step: Period Selection */}
|
||||||
{currentStep === 'period' && classicOptions && (
|
{currentStep === 'period' && classicOptions && (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
{classicOptions.periods.map((period) => (
|
{classicOptions.periods.map((period) => {
|
||||||
<button
|
const hasExistingDiscount = !!(period.discount_percent && period.discount_percent > 0)
|
||||||
key={period.id}
|
const promoPeriod = applyPromoDiscount(period.price_kopeks, hasExistingDiscount)
|
||||||
onClick={() => {
|
const displayDiscount = hasExistingDiscount ? period.discount_percent : promoPeriod.percent
|
||||||
setSelectedPeriod(period)
|
const displayOriginal = hasExistingDiscount ? period.original_price_kopeks : promoPeriod.original
|
||||||
if (period.traffic.current !== undefined) {
|
|
||||||
setSelectedTraffic(period.traffic.current)
|
return (
|
||||||
}
|
<button
|
||||||
if (period.servers.selected) {
|
key={period.id}
|
||||||
setSelectedServers(period.servers.selected)
|
onClick={() => {
|
||||||
}
|
setSelectedPeriod(period)
|
||||||
if (period.devices.current) {
|
if (period.traffic.current !== undefined) {
|
||||||
setSelectedDevices(period.devices.current)
|
setSelectedTraffic(period.traffic.current)
|
||||||
}
|
}
|
||||||
}}
|
if (period.servers.selected) {
|
||||||
className={`p-4 rounded-xl border text-left transition-all ${
|
setSelectedServers(period.servers.selected)
|
||||||
selectedPeriod?.id === period.id
|
}
|
||||||
? 'border-accent-500 bg-accent-500/10'
|
if (period.devices.current) {
|
||||||
: 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
|
setSelectedDevices(period.devices.current)
|
||||||
}`}
|
}
|
||||||
>
|
}}
|
||||||
<div className="text-lg font-semibold text-dark-100">{period.label}</div>
|
className={`p-4 rounded-xl border text-left transition-all relative ${
|
||||||
<div className="text-accent-400 font-medium">{formatPrice(period.price_kopeks)}</div>
|
selectedPeriod?.id === period.id
|
||||||
{(period.discount_percent ?? 0) > 0 && (
|
? 'border-accent-500 bg-accent-500/10'
|
||||||
<span className="badge-success text-xs mt-2 inline-block">-{period.discount_percent}%</span>
|
: 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
|
||||||
)}
|
}`}
|
||||||
</button>
|
>
|
||||||
))}
|
{displayDiscount && displayDiscount > 0 && (
|
||||||
|
<div className={`absolute -top-2 -right-2 px-2 py-0.5 text-white text-xs font-medium rounded-full ${
|
||||||
|
hasExistingDiscount ? 'bg-success-500' : 'bg-orange-500'
|
||||||
|
}`}>
|
||||||
|
-{displayDiscount}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-lg font-semibold text-dark-100">{period.label}</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-accent-400 font-medium">{formatPrice(promoPeriod.price)}</span>
|
||||||
|
{displayOriginal && displayOriginal > promoPeriod.price && (
|
||||||
|
<span className="text-dark-500 text-sm line-through">{formatPrice(displayOriginal)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step: Traffic Selection */}
|
{/* Step: Traffic Selection */}
|
||||||
{currentStep === 'traffic' && selectedPeriod?.traffic.options && (
|
{currentStep === 'traffic' && selectedPeriod?.traffic.options && (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
{selectedPeriod.traffic.options.map((option) => (
|
{selectedPeriod.traffic.options.map((option) => {
|
||||||
<button
|
const hasExistingDiscount = !!(option.discount_percent && option.discount_percent > 0)
|
||||||
key={option.value}
|
const promoTraffic = applyPromoDiscount(option.price_kopeks, hasExistingDiscount)
|
||||||
onClick={() => setSelectedTraffic(option.value)}
|
|
||||||
disabled={!option.is_available}
|
return (
|
||||||
className={`p-4 rounded-xl border text-center transition-all ${
|
<button
|
||||||
selectedTraffic === option.value
|
key={option.value}
|
||||||
? 'border-accent-500 bg-accent-500/10'
|
onClick={() => setSelectedTraffic(option.value)}
|
||||||
: option.is_available
|
disabled={!option.is_available}
|
||||||
? 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
|
className={`p-4 rounded-xl border text-center transition-all relative ${
|
||||||
: 'border-dark-800/30 bg-dark-900/30 opacity-50 cursor-not-allowed'
|
selectedTraffic === option.value
|
||||||
}`}
|
? 'border-accent-500 bg-accent-500/10'
|
||||||
>
|
: option.is_available
|
||||||
<div className="text-lg font-semibold text-dark-100">{option.label}</div>
|
? 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
|
||||||
<div className="text-accent-400">{formatPrice(option.price_kopeks)}</div>
|
: 'border-dark-800/30 bg-dark-900/30 opacity-50 cursor-not-allowed'
|
||||||
</button>
|
}`}
|
||||||
))}
|
>
|
||||||
|
{promoTraffic.percent && (
|
||||||
|
<div className="absolute -top-2 -right-2 px-2 py-0.5 bg-orange-500 text-white text-xs font-medium rounded-full">
|
||||||
|
-{promoTraffic.percent}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-lg font-semibold text-dark-100">{option.label}</div>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<span className="text-accent-400">{formatPrice(promoTraffic.price)}</span>
|
||||||
|
{promoTraffic.original && (
|
||||||
|
<span className="text-dark-500 text-xs line-through">{formatPrice(promoTraffic.original)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step: Server Selection */}
|
{/* Step: Server Selection */}
|
||||||
{currentStep === 'servers' && selectedPeriod?.servers.options && (
|
{currentStep === 'servers' && selectedPeriod?.servers.options && (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
{selectedPeriod.servers.options.map((server) => (
|
{selectedPeriod.servers.options.map((server) => {
|
||||||
<button
|
const hasExistingDiscount = !!(server.discount_percent && server.discount_percent > 0)
|
||||||
key={server.uuid}
|
const promoServer = applyPromoDiscount(server.price_kopeks, hasExistingDiscount)
|
||||||
onClick={() => toggleServer(server.uuid)}
|
|
||||||
disabled={!server.is_available}
|
return (
|
||||||
className={`p-4 rounded-xl border text-left transition-all ${
|
<button
|
||||||
selectedServers.includes(server.uuid)
|
key={server.uuid}
|
||||||
? 'border-accent-500 bg-accent-500/10'
|
onClick={() => toggleServer(server.uuid)}
|
||||||
: server.is_available
|
disabled={!server.is_available}
|
||||||
? 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
|
className={`p-4 rounded-xl border text-left transition-all relative ${
|
||||||
: 'border-dark-800/30 bg-dark-900/30 opacity-50 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 ${
|
|
||||||
selectedServers.includes(server.uuid)
|
selectedServers.includes(server.uuid)
|
||||||
? 'border-accent-500 bg-accent-500'
|
? 'border-accent-500 bg-accent-500/10'
|
||||||
: 'border-dark-600'
|
: server.is_available
|
||||||
}`}>
|
? 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
|
||||||
{selectedServers.includes(server.uuid) && (
|
: 'border-dark-800/30 bg-dark-900/30 opacity-50 cursor-not-allowed'
|
||||||
<CheckIcon />
|
}`}
|
||||||
)}
|
>
|
||||||
|
{promoServer.percent && (
|
||||||
|
<div className="absolute -top-2 -right-2 px-2 py-0.5 bg-orange-500 text-white text-xs font-medium rounded-full">
|
||||||
|
-{promoServer.percent}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 ${
|
||||||
|
selectedServers.includes(server.uuid)
|
||||||
|
? 'border-accent-500 bg-accent-500'
|
||||||
|
: 'border-dark-600'
|
||||||
|
}`}>
|
||||||
|
{selectedServers.includes(server.uuid) && (
|
||||||
|
<CheckIcon />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-dark-100">{server.name}</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-accent-400">{formatPrice(promoServer.price)}</span>
|
||||||
|
{promoServer.original && (
|
||||||
|
<span className="text-dark-500 text-xs line-through">{formatPrice(promoServer.original)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</button>
|
||||||
<div className="font-medium text-dark-100">{server.name}</div>
|
)
|
||||||
<div className="text-sm text-accent-400">{formatPrice(server.price_kopeks)}</div>
|
})}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1723,6 +1875,18 @@ export default function Subscription() {
|
|||||||
</div>
|
</div>
|
||||||
) : preview ? (
|
) : preview ? (
|
||||||
<div className="bg-dark-800/50 rounded-xl p-5 space-y-4">
|
<div className="bg-dark-800/50 rounded-xl p-5 space-y-4">
|
||||||
|
{/* Active promo discount banner */}
|
||||||
|
{activeDiscount?.is_active && activeDiscount.discount_percent && !preview.original_price_kopeks && (
|
||||||
|
<div className="flex items-center justify-center gap-2 p-3 bg-orange-500/10 border border-orange-500/30 rounded-lg">
|
||||||
|
<svg className="w-4 h-4 text-orange-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<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>
|
||||||
|
<span className="text-orange-400 text-sm font-medium">
|
||||||
|
{t('promo.discountApplied')} -{activeDiscount.discount_percent}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{preview.breakdown.map((item, idx) => (
|
{preview.breakdown.map((item, idx) => (
|
||||||
<div key={idx} className="flex justify-between text-dark-300">
|
<div key={idx} className="flex justify-between text-dark-300">
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
@@ -1730,15 +1894,25 @@ export default function Subscription() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="border-t border-dark-700/50 pt-4 flex justify-between items-center">
|
{(() => {
|
||||||
<span className="font-semibold text-dark-100 text-lg">{t('subscription.total')}</span>
|
// Apply promo discount if not already applied by server
|
||||||
<div className="text-right">
|
const hasServerDiscount = !!preview.original_price_kopeks
|
||||||
<div className="text-2xl font-bold text-accent-400">{formatPrice(preview.total_price_kopeks)}</div>
|
const promoTotal = applyPromoDiscount(preview.total_price_kopeks, hasServerDiscount)
|
||||||
{preview.original_price_kopeks && (
|
const displayTotal = promoTotal.price
|
||||||
<div className="text-sm text-dark-500 line-through">{formatPrice(preview.original_price_kopeks)}</div>
|
const displayOriginal = hasServerDiscount ? preview.original_price_kopeks : promoTotal.original
|
||||||
)}
|
|
||||||
</div>
|
return (
|
||||||
</div>
|
<div className="border-t border-dark-700/50 pt-4 flex justify-between items-center">
|
||||||
|
<span className="font-semibold text-dark-100 text-lg">{t('subscription.total')}</span>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold text-accent-400">{formatPrice(displayTotal)}</div>
|
||||||
|
{displayOriginal && (
|
||||||
|
<div className="text-sm text-dark-500 line-through">{formatPrice(displayOriginal)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{preview.discount_label && (
|
{preview.discount_label && (
|
||||||
<div className="text-sm text-success-400 text-center">{preview.discount_label}</div>
|
<div className="text-sm text-success-400 text-center">{preview.discount_label}</div>
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ticketsApi } from '../api/tickets'
|
import { ticketsApi } from '../api/tickets'
|
||||||
import { infoApi } from '../api/info'
|
import { infoApi } from '../api/info'
|
||||||
|
import { logger } from '../utils/logger'
|
||||||
import type { TicketDetail, TicketMessage } from '../types'
|
import type { TicketDetail, TicketMessage } from '../types'
|
||||||
|
|
||||||
|
const log = logger.createLogger('Support')
|
||||||
|
|
||||||
const PlusIcon = () => (
|
const PlusIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
@@ -120,7 +123,7 @@ function MessageMedia({ message, t }: { message: TicketMessage; t: (key: string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Support() {
|
export default function Support() {
|
||||||
console.log('[Support] Component loaded - VERSION 2024-01-16-v3')
|
log.debug('Component loaded')
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -271,20 +274,20 @@ export default function Support() {
|
|||||||
|
|
||||||
// If tickets are disabled, show redirect message
|
// If tickets are disabled, show redirect message
|
||||||
if (supportConfig && !supportConfig.tickets_enabled) {
|
if (supportConfig && !supportConfig.tickets_enabled) {
|
||||||
console.log('[Support] Tickets disabled, config:', supportConfig)
|
log.debug('Tickets disabled, config:', supportConfig)
|
||||||
|
|
||||||
const getSupportMessage = () => {
|
const getSupportMessage = () => {
|
||||||
console.log('[Support] Getting support message for type:', supportConfig.support_type)
|
log.debug('Getting support message for type:', supportConfig.support_type)
|
||||||
|
|
||||||
if (supportConfig.support_type === 'profile') {
|
if (supportConfig.support_type === 'profile') {
|
||||||
const supportUsername = supportConfig.support_username || '@support'
|
const supportUsername = supportConfig.support_username || '@support'
|
||||||
console.log('[Support] Opening profile:', supportUsername)
|
log.debug('Opening profile:', supportUsername)
|
||||||
return {
|
return {
|
||||||
title: t('support.ticketsDisabled'),
|
title: t('support.ticketsDisabled'),
|
||||||
message: t('support.contactSupport', { username: supportUsername }),
|
message: t('support.contactSupport', { username: supportUsername }),
|
||||||
buttonText: t('support.contactUs'),
|
buttonText: t('support.contactUs'),
|
||||||
buttonAction: () => {
|
buttonAction: () => {
|
||||||
console.log('[Support] Button clicked, opening:', supportUsername)
|
log.debug('Button clicked, opening:', supportUsername)
|
||||||
const webApp = window.Telegram?.WebApp
|
const webApp = window.Telegram?.WebApp
|
||||||
|
|
||||||
// Extract username without @
|
// Extract username without @
|
||||||
@@ -292,41 +295,36 @@ export default function Support() {
|
|||||||
? supportUsername.slice(1)
|
? supportUsername.slice(1)
|
||||||
: supportUsername
|
: supportUsername
|
||||||
|
|
||||||
// Try different URL formats
|
|
||||||
const telegramUrl = `tg://resolve?domain=${username}`
|
|
||||||
const webUrl = `https://t.me/${username}`
|
const webUrl = `https://t.me/${username}`
|
||||||
|
log.debug('Web URL:', webUrl, 'WebApp methods:', {
|
||||||
console.log('[Support] Telegram URL:', telegramUrl)
|
|
||||||
console.log('[Support] Web URL:', webUrl)
|
|
||||||
console.log('[Support] WebApp methods:', {
|
|
||||||
openTelegramLink: !!webApp?.openTelegramLink,
|
openTelegramLink: !!webApp?.openTelegramLink,
|
||||||
openLink: !!webApp?.openLink,
|
openLink: !!webApp?.openLink,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Try openTelegramLink first (for tg:// links)
|
// Try openTelegramLink first (for tg:// links)
|
||||||
if (webApp?.openTelegramLink) {
|
if (webApp?.openTelegramLink) {
|
||||||
console.log('[Support] Using openTelegramLink with web URL')
|
log.debug('Using openTelegramLink with web URL')
|
||||||
try {
|
try {
|
||||||
webApp.openTelegramLink(webUrl)
|
webApp.openTelegramLink(webUrl)
|
||||||
return
|
return
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Support] openTelegramLink failed:', e)
|
log.error('openTelegramLink failed:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to openLink
|
// Fallback to openLink
|
||||||
if (webApp?.openLink) {
|
if (webApp?.openLink) {
|
||||||
console.log('[Support] Using openLink')
|
log.debug('Using openLink')
|
||||||
try {
|
try {
|
||||||
webApp.openLink(webUrl)
|
webApp.openLink(webUrl)
|
||||||
return
|
return
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Support] openLink failed:', e)
|
log.error('openLink failed:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last resort - window.open
|
// Last resort - window.open
|
||||||
console.log('[Support] Using window.open')
|
log.debug('Using window.open')
|
||||||
window.open(webUrl, '_blank')
|
window.open(webUrl, '_blank')
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -350,13 +348,13 @@ export default function Support() {
|
|||||||
|
|
||||||
// Fallback: contact support (should not normally happen if config is correct)
|
// Fallback: contact support (should not normally happen if config is correct)
|
||||||
const supportUsername = supportConfig.support_username || '@support'
|
const supportUsername = supportConfig.support_username || '@support'
|
||||||
console.log('[Support] Fallback: Opening profile:', supportUsername)
|
log.debug('Fallback: Opening profile:', supportUsername)
|
||||||
return {
|
return {
|
||||||
title: t('support.ticketsDisabled'),
|
title: t('support.ticketsDisabled'),
|
||||||
message: t('support.contactSupport', { username: supportUsername }),
|
message: t('support.contactSupport', { username: supportUsername }),
|
||||||
buttonText: t('support.contactUs'),
|
buttonText: t('support.contactUs'),
|
||||||
buttonAction: () => {
|
buttonAction: () => {
|
||||||
console.log('[Support] Fallback button clicked, opening:', supportUsername)
|
log.debug('Fallback button clicked, opening:', supportUsername)
|
||||||
const webApp = window.Telegram?.WebApp
|
const webApp = window.Telegram?.WebApp
|
||||||
|
|
||||||
// Extract username without @
|
// Extract username without @
|
||||||
@@ -365,16 +363,16 @@ export default function Support() {
|
|||||||
: supportUsername
|
: supportUsername
|
||||||
|
|
||||||
const webUrl = `https://t.me/${username}`
|
const webUrl = `https://t.me/${username}`
|
||||||
console.log('[Support] Fallback opening URL:', webUrl)
|
log.debug('Fallback opening URL:', webUrl)
|
||||||
|
|
||||||
if (webApp?.openTelegramLink) {
|
if (webApp?.openTelegramLink) {
|
||||||
console.log('[Support] Fallback using openTelegramLink')
|
log.debug('Fallback using openTelegramLink')
|
||||||
webApp.openTelegramLink(webUrl)
|
webApp.openTelegramLink(webUrl)
|
||||||
} else if (webApp?.openLink) {
|
} else if (webApp?.openLink) {
|
||||||
console.log('[Support] Fallback using openLink')
|
log.debug('Fallback using openLink')
|
||||||
webApp.openLink(webUrl)
|
webApp.openLink(webUrl)
|
||||||
} else {
|
} else {
|
||||||
console.log('[Support] Fallback using window.open')
|
log.debug('Fallback using window.open')
|
||||||
window.open(webUrl, '_blank')
|
window.open(webUrl, '_blank')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,19 +32,27 @@ export default function TelegramCallback() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse and validate numeric fields
|
||||||
|
const parsedId = parseInt(id, 10)
|
||||||
|
const parsedAuthDate = parseInt(authDate, 10)
|
||||||
|
|
||||||
|
if (Number.isNaN(parsedId) || Number.isNaN(parsedAuthDate)) {
|
||||||
|
setError(t('auth.telegramRequired'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loginWithTelegramWidget({
|
await loginWithTelegramWidget({
|
||||||
id: parseInt(id, 10),
|
id: parsedId,
|
||||||
first_name: firstName,
|
first_name: firstName,
|
||||||
last_name: lastName || undefined,
|
last_name: lastName || undefined,
|
||||||
username: username || undefined,
|
username: username || undefined,
|
||||||
photo_url: photoUrl || undefined,
|
photo_url: photoUrl || undefined,
|
||||||
auth_date: parseInt(authDate, 10),
|
auth_date: parsedAuthDate,
|
||||||
hash: hash,
|
hash: hash,
|
||||||
})
|
})
|
||||||
navigate('/')
|
navigate('/')
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Telegram auth error:', err)
|
|
||||||
const error = err as { response?: { data?: { detail?: string } } }
|
const error = err as { response?: { data?: { detail?: string } } }
|
||||||
setError(error.response?.data?.detail || t('common.error'))
|
setError(error.response?.data?.detail || t('common.error'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,29 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { useAuthStore } from '../store/auth'
|
import { useAuthStore } from '../store/auth'
|
||||||
import { brandingApi } from '../api/branding'
|
import { brandingApi } from '../api/branding'
|
||||||
|
|
||||||
|
// Validate redirect URL to prevent open redirect attacks
|
||||||
|
const getSafeRedirectUrl = (url: string | null): string => {
|
||||||
|
if (!url) return '/'
|
||||||
|
// Only allow relative paths starting with /
|
||||||
|
// Block protocol-relative URLs (//evil.com) and absolute URLs
|
||||||
|
if (!url.startsWith('/') || url.startsWith('//')) {
|
||||||
|
return '/'
|
||||||
|
}
|
||||||
|
// Additional check for encoded characters that could bypass validation
|
||||||
|
try {
|
||||||
|
const decoded = decodeURIComponent(url)
|
||||||
|
if (!decoded.startsWith('/') || decoded.startsWith('//') || decoded.includes('://')) {
|
||||||
|
return '/'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return '/'
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_RETRY_ATTEMPTS = 3
|
||||||
|
const RETRY_COUNT_KEY = 'telegram_redirect_retry_count'
|
||||||
|
|
||||||
export default function TelegramRedirect() {
|
export default function TelegramRedirect() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -12,6 +35,10 @@ export default function TelegramRedirect() {
|
|||||||
const { loginWithTelegram, isAuthenticated, isLoading: authLoading } = useAuthStore()
|
const { loginWithTelegram, isAuthenticated, isLoading: authLoading } = useAuthStore()
|
||||||
const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'not-telegram'>('loading')
|
const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'not-telegram'>('loading')
|
||||||
const [errorMessage, setErrorMessage] = useState('')
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
const [retryCount, setRetryCount] = useState(() => {
|
||||||
|
const stored = sessionStorage.getItem(RETRY_COUNT_KEY)
|
||||||
|
return stored ? parseInt(stored, 10) : 0
|
||||||
|
})
|
||||||
|
|
||||||
// Get branding for nice display
|
// Get branding for nice display
|
||||||
const { data: branding } = useQuery({
|
const { data: branding } = useQuery({
|
||||||
@@ -24,8 +51,8 @@ export default function TelegramRedirect() {
|
|||||||
const logoLetter = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'
|
const logoLetter = branding?.logo_letter || import.meta.env.VITE_APP_LOGO || 'V'
|
||||||
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null
|
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null
|
||||||
|
|
||||||
// Get redirect target from URL params
|
// Get redirect target from URL params (validated)
|
||||||
const redirectTo = searchParams.get('redirect') || '/'
|
const redirectTo = getSafeRedirectUrl(searchParams.get('redirect'))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If already authenticated, redirect immediately
|
// If already authenticated, redirect immediately
|
||||||
@@ -76,13 +103,28 @@ export default function TelegramRedirect() {
|
|||||||
setTimeout(initTelegram, 300)
|
setTimeout(initTelegram, 300)
|
||||||
}, [loginWithTelegram, navigate, isAuthenticated, authLoading, redirectTo, t])
|
}, [loginWithTelegram, navigate, isAuthenticated, authLoading, redirectTo, t])
|
||||||
|
|
||||||
// Handle retry
|
// Handle retry with limit to prevent infinite loops
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
|
if (retryCount >= MAX_RETRY_ATTEMPTS) {
|
||||||
|
setErrorMessage('Превышено количество попыток. Попробуйте позже.')
|
||||||
|
sessionStorage.removeItem(RETRY_COUNT_KEY)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const newCount = retryCount + 1
|
||||||
|
setRetryCount(newCount)
|
||||||
|
sessionStorage.setItem(RETRY_COUNT_KEY, String(newCount))
|
||||||
setStatus('loading')
|
setStatus('loading')
|
||||||
setErrorMessage('')
|
setErrorMessage('')
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear retry count on successful auth
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'success') {
|
||||||
|
sessionStorage.removeItem(RETRY_COUNT_KEY)
|
||||||
|
}
|
||||||
|
}, [status])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4">
|
<div className="min-h-screen flex items-center justify-center p-4">
|
||||||
{/* Background */}
|
{/* Background */}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { create } from 'zustand'
|
|||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
import type { User } from '../types'
|
import type { User } from '../types'
|
||||||
import { authApi } from '../api/auth'
|
import { authApi } from '../api/auth'
|
||||||
|
import { apiClient } from '../api/client'
|
||||||
|
import { tokenStorage, isTokenValid, tokenRefreshManager } from '../utils/token'
|
||||||
|
|
||||||
export interface TelegramWidgetData {
|
export interface TelegramWidgetData {
|
||||||
id: number
|
id: number
|
||||||
@@ -33,6 +35,14 @@ interface AuthState {
|
|||||||
loginWithEmail: (email: string, password: string) => Promise<void>
|
loginWithEmail: (email: string, password: string) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Блокировка для предотвращения race condition при инициализации
|
||||||
|
// Используем объект для атомарности операций
|
||||||
|
const initState = {
|
||||||
|
promise: null as Promise<void> | null,
|
||||||
|
isInitializing: false,
|
||||||
|
isInitialized: false,
|
||||||
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
@@ -44,8 +54,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
|
|
||||||
setTokens: (accessToken, refreshToken) => {
|
setTokens: (accessToken, refreshToken) => {
|
||||||
localStorage.setItem('access_token', accessToken)
|
tokenStorage.setTokens(accessToken, refreshToken)
|
||||||
localStorage.setItem('refresh_token', refreshToken)
|
|
||||||
set({
|
set({
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
@@ -64,10 +73,11 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
logout: () => {
|
logout: () => {
|
||||||
const { refreshToken } = get()
|
const { refreshToken } = get()
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
authApi.logout(refreshToken).catch(console.error)
|
authApi.logout(refreshToken).catch(() => {
|
||||||
|
// Logout API call failed - ignore silently
|
||||||
|
})
|
||||||
}
|
}
|
||||||
localStorage.removeItem('access_token')
|
tokenStorage.clearTokens()
|
||||||
localStorage.removeItem('refresh_token')
|
|
||||||
set({
|
set({
|
||||||
accessToken: null,
|
accessToken: null,
|
||||||
refreshToken: null,
|
refreshToken: null,
|
||||||
@@ -79,17 +89,15 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
checkAdminStatus: async () => {
|
checkAdminStatus: async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/cabinet/auth/me/is-admin', {
|
const token = tokenStorage.getAccessToken()
|
||||||
headers: {
|
if (!token || !isTokenValid(token)) {
|
||||||
Authorization: `Bearer ${localStorage.getItem('access_token')}`,
|
set({ isAdmin: false })
|
||||||
},
|
return
|
||||||
})
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json()
|
|
||||||
set({ isAdmin: data.is_admin })
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
// Используем apiClient для единообразной обработки ошибок
|
||||||
console.error('Failed to check admin status:', error)
|
const response = await apiClient.get<{ is_admin: boolean }>('/cabinet/auth/me/is-admin')
|
||||||
|
set({ isAdmin: response.data.is_admin })
|
||||||
|
} catch {
|
||||||
set({ isAdmin: false })
|
set({ isAdmin: false })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -98,66 +106,124 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
try {
|
try {
|
||||||
const user = await authApi.getMe()
|
const user = await authApi.getMe()
|
||||||
set({ user })
|
set({ user })
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Failed to refresh user:', error)
|
// Failed to refresh user - ignore silently
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
initialize: async () => {
|
initialize: async () => {
|
||||||
set({ isLoading: true })
|
// Защита от race condition - если уже инициализировано, выходим
|
||||||
const accessToken = localStorage.getItem('access_token')
|
if (initState.isInitialized) {
|
||||||
const refreshToken = localStorage.getItem('refresh_token')
|
|
||||||
|
|
||||||
if (!accessToken || !refreshToken) {
|
|
||||||
set({ isLoading: false, isAuthenticated: false })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Если уже идёт инициализация, ждём её завершения
|
||||||
const user = await authApi.getMe()
|
if (initState.isInitializing && initState.promise) {
|
||||||
set({
|
return initState.promise
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
user,
|
|
||||||
isAuthenticated: true,
|
|
||||||
isLoading: false,
|
|
||||||
})
|
|
||||||
// Check admin status
|
|
||||||
get().checkAdminStatus()
|
|
||||||
} catch (error) {
|
|
||||||
// Token might be expired, try to refresh
|
|
||||||
try {
|
|
||||||
const response = await authApi.refreshToken(refreshToken)
|
|
||||||
localStorage.setItem('access_token', response.access_token)
|
|
||||||
const user = await authApi.getMe()
|
|
||||||
set({
|
|
||||||
accessToken: response.access_token,
|
|
||||||
refreshToken,
|
|
||||||
user,
|
|
||||||
isAuthenticated: true,
|
|
||||||
isLoading: false,
|
|
||||||
})
|
|
||||||
// Check admin status
|
|
||||||
get().checkAdminStatus()
|
|
||||||
} catch {
|
|
||||||
// Refresh failed, logout
|
|
||||||
localStorage.removeItem('access_token')
|
|
||||||
localStorage.removeItem('refresh_token')
|
|
||||||
set({
|
|
||||||
accessToken: null,
|
|
||||||
refreshToken: null,
|
|
||||||
user: null,
|
|
||||||
isAuthenticated: false,
|
|
||||||
isLoading: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initState.isInitializing = true
|
||||||
|
initState.promise = (async () => {
|
||||||
|
try {
|
||||||
|
set({ isLoading: true })
|
||||||
|
|
||||||
|
// Миграция токенов из localStorage (для обратной совместимости)
|
||||||
|
tokenStorage.migrateFromLocalStorage()
|
||||||
|
|
||||||
|
const accessToken = tokenStorage.getAccessToken()
|
||||||
|
const refreshToken = tokenStorage.getRefreshToken()
|
||||||
|
|
||||||
|
if (!accessToken || !refreshToken) {
|
||||||
|
set({ isLoading: false, isAuthenticated: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем валидность токена перед использованием
|
||||||
|
if (!isTokenValid(accessToken)) {
|
||||||
|
// Используем централизованный менеджер для refresh
|
||||||
|
const newToken = await tokenRefreshManager.refreshAccessToken()
|
||||||
|
if (newToken) {
|
||||||
|
const user = await authApi.getMe()
|
||||||
|
set({
|
||||||
|
accessToken: newToken,
|
||||||
|
refreshToken,
|
||||||
|
user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
get().checkAdminStatus()
|
||||||
|
} else {
|
||||||
|
tokenStorage.clearTokens()
|
||||||
|
set({
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await authApi.getMe()
|
||||||
|
set({
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
get().checkAdminStatus()
|
||||||
|
} catch {
|
||||||
|
// Token might be invalid on server, try to refresh
|
||||||
|
const newToken = await tokenRefreshManager.refreshAccessToken()
|
||||||
|
if (newToken) {
|
||||||
|
try {
|
||||||
|
const user = await authApi.getMe()
|
||||||
|
set({
|
||||||
|
accessToken: newToken,
|
||||||
|
refreshToken,
|
||||||
|
user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
get().checkAdminStatus()
|
||||||
|
} catch {
|
||||||
|
tokenStorage.clearTokens()
|
||||||
|
set({
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Refresh failed, logout
|
||||||
|
tokenStorage.clearTokens()
|
||||||
|
set({
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
initState.isInitializing = false
|
||||||
|
initState.isInitialized = true
|
||||||
|
initState.promise = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
return initState.promise
|
||||||
},
|
},
|
||||||
|
|
||||||
loginWithTelegram: async (initData) => {
|
loginWithTelegram: async (initData) => {
|
||||||
const response = await authApi.loginTelegram(initData)
|
const response = await authApi.loginTelegram(initData)
|
||||||
localStorage.setItem('access_token', response.access_token)
|
tokenStorage.setTokens(response.access_token, response.refresh_token)
|
||||||
localStorage.setItem('refresh_token', response.refresh_token)
|
|
||||||
set({
|
set({
|
||||||
accessToken: response.access_token,
|
accessToken: response.access_token,
|
||||||
refreshToken: response.refresh_token,
|
refreshToken: response.refresh_token,
|
||||||
@@ -169,8 +235,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
loginWithTelegramWidget: async (data) => {
|
loginWithTelegramWidget: async (data) => {
|
||||||
const response = await authApi.loginTelegramWidget(data)
|
const response = await authApi.loginTelegramWidget(data)
|
||||||
localStorage.setItem('access_token', response.access_token)
|
tokenStorage.setTokens(response.access_token, response.refresh_token)
|
||||||
localStorage.setItem('refresh_token', response.refresh_token)
|
|
||||||
set({
|
set({
|
||||||
accessToken: response.access_token,
|
accessToken: response.access_token,
|
||||||
refreshToken: response.refresh_token,
|
refreshToken: response.refresh_token,
|
||||||
@@ -182,8 +247,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
loginWithEmail: async (email, password) => {
|
loginWithEmail: async (email, password) => {
|
||||||
const response = await authApi.loginEmail(email, password)
|
const response = await authApi.loginEmail(email, password)
|
||||||
localStorage.setItem('access_token', response.access_token)
|
tokenStorage.setTokens(response.access_token, response.refresh_token)
|
||||||
localStorage.setItem('refresh_token', response.refresh_token)
|
|
||||||
set({
|
set({
|
||||||
accessToken: response.access_token,
|
accessToken: response.access_token,
|
||||||
refreshToken: response.refresh_token,
|
refreshToken: response.refresh_token,
|
||||||
|
|||||||
@@ -486,3 +486,33 @@ export interface AppConfig {
|
|||||||
supportUrl?: string
|
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
|
||||||
|
}
|
||||||
|
|||||||
57
src/utils/logger.ts
Normal file
57
src/utils/logger.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Утилита для логирования только в development режиме
|
||||||
|
* В production логи не выводятся
|
||||||
|
*/
|
||||||
|
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
|
||||||
|
export const logger = {
|
||||||
|
log: (...args: unknown[]): void => {
|
||||||
|
if (isDev) {
|
||||||
|
console.log(...args)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
warn: (...args: unknown[]): void => {
|
||||||
|
if (isDev) {
|
||||||
|
console.warn(...args)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
error: (...args: unknown[]): void => {
|
||||||
|
// Ошибки логируем всегда (важно для отладки в production)
|
||||||
|
console.error(...args)
|
||||||
|
},
|
||||||
|
|
||||||
|
debug: (...args: unknown[]): void => {
|
||||||
|
if (isDev) {
|
||||||
|
console.debug(...args)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создаёт логгер с префиксом для конкретного модуля
|
||||||
|
*/
|
||||||
|
createLogger: (prefix: string) => ({
|
||||||
|
log: (...args: unknown[]): void => {
|
||||||
|
if (isDev) {
|
||||||
|
console.log(`[${prefix}]`, ...args)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
warn: (...args: unknown[]): void => {
|
||||||
|
if (isDev) {
|
||||||
|
console.warn(`[${prefix}]`, ...args)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: (...args: unknown[]): void => {
|
||||||
|
console.error(`[${prefix}]`, ...args)
|
||||||
|
},
|
||||||
|
debug: (...args: unknown[]): void => {
|
||||||
|
if (isDev) {
|
||||||
|
console.debug(`[${prefix}]`, ...args)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
export default logger
|
||||||
290
src/utils/token.ts
Normal file
290
src/utils/token.ts
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
/**
|
||||||
|
* Утилиты для безопасной работы с JWT токенами
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const TOKEN_KEYS = {
|
||||||
|
ACCESS: 'access_token',
|
||||||
|
REFRESH: 'refresh_token',
|
||||||
|
USER: 'user',
|
||||||
|
TELEGRAM_INIT: 'telegram_init_data',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
interface JWTPayload {
|
||||||
|
exp?: number
|
||||||
|
iat?: number
|
||||||
|
sub?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Декодирует JWT токен без верификации подписи
|
||||||
|
* Используется только для чтения payload на клиенте
|
||||||
|
*/
|
||||||
|
export function decodeJWT(token: string): JWTPayload | null {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.')
|
||||||
|
if (parts.length !== 3) return null
|
||||||
|
|
||||||
|
const payload = parts[1]
|
||||||
|
const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
|
||||||
|
return JSON.parse(decoded)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, истёк ли срок действия токена
|
||||||
|
* @param token JWT токен
|
||||||
|
* @param bufferSeconds Буфер в секундах до истечения (по умолчанию 30 сек)
|
||||||
|
*/
|
||||||
|
export function isTokenExpired(token: string | null, bufferSeconds = 30): boolean {
|
||||||
|
if (!token) return true
|
||||||
|
|
||||||
|
const payload = decodeJWT(token)
|
||||||
|
if (!payload?.exp) return true
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
return payload.exp <= now + bufferSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, валиден ли токен (не истёк и корректный формат)
|
||||||
|
*/
|
||||||
|
export function isTokenValid(token: string | null): boolean {
|
||||||
|
if (!token) return false
|
||||||
|
return !isTokenExpired(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Безопасное хранилище токенов
|
||||||
|
* Использует sessionStorage вместо localStorage для защиты от XSS
|
||||||
|
* Токены не сохраняются между сессиями браузера
|
||||||
|
*/
|
||||||
|
export const tokenStorage = {
|
||||||
|
getAccessToken(): string | null {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(TOKEN_KEYS.ACCESS)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getRefreshToken(): string | null {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(TOKEN_KEYS.REFRESH)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setTokens(accessToken: string, refreshToken: string): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken)
|
||||||
|
sessionStorage.setItem(TOKEN_KEYS.REFRESH, refreshToken)
|
||||||
|
} catch {
|
||||||
|
console.error('Failed to save tokens to sessionStorage')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setAccessToken(accessToken: string): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken)
|
||||||
|
} catch {
|
||||||
|
console.error('Failed to save access token to sessionStorage')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearTokens(): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(TOKEN_KEYS.ACCESS)
|
||||||
|
sessionStorage.removeItem(TOKEN_KEYS.REFRESH)
|
||||||
|
sessionStorage.removeItem(TOKEN_KEYS.USER)
|
||||||
|
// Также очищаем localStorage для миграции со старой версии
|
||||||
|
localStorage.removeItem(TOKEN_KEYS.ACCESS)
|
||||||
|
localStorage.removeItem(TOKEN_KEYS.REFRESH)
|
||||||
|
localStorage.removeItem(TOKEN_KEYS.USER)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Миграция токенов из localStorage в sessionStorage
|
||||||
|
* Вызывается при инициализации для обратной совместимости
|
||||||
|
*/
|
||||||
|
migrateFromLocalStorage(): void {
|
||||||
|
try {
|
||||||
|
const accessToken = localStorage.getItem(TOKEN_KEYS.ACCESS)
|
||||||
|
const refreshToken = localStorage.getItem(TOKEN_KEYS.REFRESH)
|
||||||
|
|
||||||
|
if (accessToken && !sessionStorage.getItem(TOKEN_KEYS.ACCESS)) {
|
||||||
|
sessionStorage.setItem(TOKEN_KEYS.ACCESS, accessToken)
|
||||||
|
}
|
||||||
|
if (refreshToken && !sessionStorage.getItem(TOKEN_KEYS.REFRESH)) {
|
||||||
|
sessionStorage.setItem(TOKEN_KEYS.REFRESH, refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем из localStorage после миграции
|
||||||
|
localStorage.removeItem(TOKEN_KEYS.ACCESS)
|
||||||
|
localStorage.removeItem(TOKEN_KEYS.REFRESH)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getTelegramInitData(): string | null {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(TOKEN_KEYS.TELEGRAM_INIT)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setTelegramInitData(data: string): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(TOKEN_KEYS.TELEGRAM_INIT, data)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Централизованный менеджер обновления токенов
|
||||||
|
* Предотвращает множественные параллельные refresh запросы
|
||||||
|
*/
|
||||||
|
class TokenRefreshManager {
|
||||||
|
private isRefreshing = false
|
||||||
|
private refreshPromise: Promise<string | null> | null = null
|
||||||
|
private subscribers: ((token: string | null) => void)[] = []
|
||||||
|
private refreshEndpoint = '/api/cabinet/auth/refresh'
|
||||||
|
|
||||||
|
setRefreshEndpoint(endpoint: string): void {
|
||||||
|
this.refreshEndpoint = endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновляет access token используя refresh token
|
||||||
|
* При множественных вызовах возвращает один и тот же Promise
|
||||||
|
*/
|
||||||
|
async refreshAccessToken(): Promise<string | null> {
|
||||||
|
// Если уже идёт refresh - возвращаем существующий Promise
|
||||||
|
if (this.isRefreshing && this.refreshPromise) {
|
||||||
|
return this.refreshPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshToken = tokenStorage.getRefreshToken()
|
||||||
|
if (!refreshToken) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRefreshing = true
|
||||||
|
this.refreshPromise = this.doRefresh(refreshToken)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.refreshPromise
|
||||||
|
this.notifySubscribers(result)
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
this.isRefreshing = false
|
||||||
|
this.refreshPromise = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doRefresh(refreshToken: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
// Используем чистый axios (не apiClient) чтобы избежать циклической зависимости
|
||||||
|
const response = await axios.post<{ access_token?: string }>(
|
||||||
|
this.refreshEndpoint,
|
||||||
|
{ refresh_token: refreshToken },
|
||||||
|
{ headers: { 'Content-Type': 'application/json' } }
|
||||||
|
)
|
||||||
|
|
||||||
|
const newAccessToken = response.data.access_token
|
||||||
|
|
||||||
|
if (newAccessToken) {
|
||||||
|
tokenStorage.setAccessToken(newAccessToken)
|
||||||
|
return newAccessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} catch {
|
||||||
|
// Token refresh failed - don't log sensitive error details
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подписка на результат refresh (для ожидающих запросов)
|
||||||
|
*/
|
||||||
|
subscribe(callback: (token: string | null) => void): () => void {
|
||||||
|
this.subscribers.push(callback)
|
||||||
|
return () => {
|
||||||
|
this.subscribers = this.subscribers.filter((cb) => cb !== callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifySubscribers(token: string | null): void {
|
||||||
|
this.subscribers.forEach((cb) => cb(token))
|
||||||
|
this.subscribers = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверяет, идёт ли сейчас refresh
|
||||||
|
*/
|
||||||
|
get isRefreshInProgress(): boolean {
|
||||||
|
return this.isRefreshing
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ожидает завершения текущего refresh (если есть)
|
||||||
|
*/
|
||||||
|
async waitForRefresh(): Promise<string | null> {
|
||||||
|
if (this.refreshPromise) {
|
||||||
|
return this.refreshPromise
|
||||||
|
}
|
||||||
|
return tokenStorage.getAccessToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tokenRefreshManager = new TokenRefreshManager()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Безопасный редирект на страницу логина
|
||||||
|
* Валидирует URL чтобы предотвратить open redirect уязвимость
|
||||||
|
*/
|
||||||
|
export function safeRedirectToLogin(): void {
|
||||||
|
// Разрешённые пути для редиректа (относительные пути нашего приложения)
|
||||||
|
const loginPath = '/login'
|
||||||
|
|
||||||
|
// Проверяем, что мы на том же origin
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// Используем только относительный путь для безопасности
|
||||||
|
window.location.href = loginPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Валидирует URL для редиректа
|
||||||
|
* Возвращает true только для безопасных URL (относительные пути или тот же origin)
|
||||||
|
*/
|
||||||
|
export function isValidRedirectUrl(url: string): boolean {
|
||||||
|
// Пустой URL - небезопасен
|
||||||
|
if (!url) return false
|
||||||
|
|
||||||
|
// Относительные пути всегда безопасны
|
||||||
|
if (url.startsWith('/') && !url.startsWith('//')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url, window.location.origin)
|
||||||
|
// Разрешаем только тот же origin
|
||||||
|
return parsed.origin === window.location.origin
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user