mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
25
.env.example
25
.env.example
@@ -1,7 +1,8 @@
|
||||
# ===== BUILD-TIME VARIABLES =====
|
||||
# These are baked into the frontend at build time
|
||||
# These variables are baked into the frontend JavaScript at build time
|
||||
# If you change them, you need to rebuild: docker compose up -d --build
|
||||
|
||||
# API URL (relative path for same-origin, or full URL for external)
|
||||
# API URL - How the browser will access the backend
|
||||
# Same server (with proxy): /api
|
||||
# Remote server (direct): https://api.yourbot.com/cabinet
|
||||
VITE_API_URL=/api
|
||||
@@ -9,7 +10,7 @@ VITE_API_URL=/api
|
||||
# Telegram bot username for Login Widget (without @)
|
||||
VITE_TELEGRAM_BOT_USERNAME=your_bot_username
|
||||
|
||||
# ===== BRANDING =====
|
||||
# ===== BRANDING (optional) =====
|
||||
# App name displayed in header and browser tab
|
||||
VITE_APP_NAME=VPN Cabinet
|
||||
|
||||
@@ -17,21 +18,7 @@ VITE_APP_NAME=VPN Cabinet
|
||||
VITE_APP_LOGO=V
|
||||
|
||||
# ===== RUNTIME VARIABLES =====
|
||||
# These are used by Caddy at runtime
|
||||
|
||||
# Backend URL for reverse proxy (only if using proxy mode with VITE_API_URL=/api)
|
||||
# Development: http://host.docker.internal:8080
|
||||
# Production (same network): http://remnawave_bot:8080
|
||||
# Remote server: https://api.yourbot.com
|
||||
BACKEND_URL=https://api.yourbot.com
|
||||
|
||||
# Port to expose the frontend
|
||||
# Port to expose the frontend on host machine
|
||||
# Default: 3000 (http://localhost:3000)
|
||||
CABINET_PORT=3000
|
||||
|
||||
# ===== PRODUCTION ONLY =====
|
||||
|
||||
# Domain for HTTPS (Caddy will auto-obtain certificates)
|
||||
DOMAIN=cabinet.yourdomain.com
|
||||
|
||||
# HTTPS port (usually 443)
|
||||
CABINET_HTTPS_PORT=443
|
||||
|
||||
42
.github/ISSUE_TEMPLATE/bug-bedolage.md
vendored
Normal file
42
.github/ISSUE_TEMPLATE/bug-bedolage.md
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: Bug Bedolage
|
||||
about: Создать баг-репорт «Бедолаге»
|
||||
title: "[BUG] Заголовок"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Описание / Конфигурация
|
||||
|
||||
Кратко описываем текущую проблему.
|
||||
|
||||
Указываем настройки из `.env` конфига (если таковые имеются):
|
||||
|
||||
```yml
|
||||
|
||||
```
|
||||
|
||||
## Шаги для воспроизведения
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Фактический результат
|
||||
|
||||
*
|
||||
*
|
||||
*
|
||||
|
||||
### Скрины поведения
|
||||
|
||||
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
|
||||
|
||||
## Ожидаемое поведение
|
||||
|
||||
Опишите, как должно работать.
|
||||
|
||||
## Дополнительная информация
|
||||
|
||||
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
|
||||
32
.github/ISSUE_TEMPLATE/feat-bedolage.md
vendored
Normal file
32
.github/ISSUE_TEMPLATE/feat-bedolage.md
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: Feat Bedolage
|
||||
about: Создать запрос на фичу «Бедолаге»
|
||||
title: "[FEAT] Заголовок"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Описание / Конфигурация
|
||||
|
||||
Кратко описываем что хотим.
|
||||
|
||||
Указываем настройки из `.env` конфига (если таковые к месту):
|
||||
|
||||
```yml
|
||||
|
||||
```
|
||||
|
||||
## Текущее поведение
|
||||
|
||||
### Скрины поведения
|
||||
|
||||
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
|
||||
|
||||
## Желаемое поведение
|
||||
|
||||
Опишите, как хотелось бы, чтобы было.
|
||||
|
||||
## Дополнительная информация
|
||||
|
||||
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.
|
||||
3
.github/workflows/docker-hub.yml
vendored
3
.github/workflows/docker-hub.yml
vendored
@@ -33,6 +33,7 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push to Docker Hub
|
||||
@@ -47,6 +48,6 @@ jobs:
|
||||
build-args: |
|
||||
VITE_API_URL=/api
|
||||
VITE_TELEGRAM_BOT_USERNAME=
|
||||
VITE_APP_NAME=Bedolaga Cabinet
|
||||
VITE_APP_NAME=Cabinet
|
||||
VITE_APP_LOGO=V
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha,prefix={{branch}}-
|
||||
type=sha
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
@@ -60,5 +60,5 @@ jobs:
|
||||
build-args: |
|
||||
VITE_API_URL=/api
|
||||
VITE_TELEGRAM_BOT_USERNAME=
|
||||
VITE_APP_NAME=Bedolaga Cabinet
|
||||
VITE_APP_NAME=Cabinet
|
||||
VITE_APP_LOGO=V
|
||||
|
||||
277
README.md
277
README.md
@@ -1,6 +1,6 @@
|
||||
# Bedolaga Cabinet - Web Interface
|
||||
|
||||
Современный веб-интерфейс личного кабинета для VPN бота на базе [Remnawave Bedolaga Telegram Bot](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot)
|
||||
Современный веб-интерфейс личного кабинета для VPN бота на базе [Remnawave Bedolaga Telegram Bot](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot).
|
||||
|
||||
## Возможности
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
- 🌐 Мультиязычность (EN/RU)
|
||||
- 📱 Адаптивный дизайн
|
||||
- 🎨 Настраиваемый брендинг
|
||||
- Админпанель для управлени ботом
|
||||
- Автоматическая передача subpage конфигов приложений из Remnawave
|
||||
- ⚡ Fast - React + Vite + TypeScript
|
||||
|
||||
## Требования
|
||||
@@ -21,17 +23,19 @@
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### Вариант 1: Готовый Docker образ
|
||||
### Вариант A: Готовый Docker образ
|
||||
|
||||
```bash
|
||||
# Из GitHub Container Registry
|
||||
docker pull ghcr.io/bedolaga-dev/bedolaga-cabinet:latest
|
||||
|
||||
# Или из Docker Hub
|
||||
или
|
||||
|
||||
docker pull bedolaga/bedolaga-cabinet:latest
|
||||
```
|
||||
|
||||
### Вариант 2: Сборка из исходников
|
||||
Затем настройте Caddy/Nginx для проксирования (см. раздел "Настройка прокси для production").
|
||||
|
||||
### Вариант B: Сборка из исходников
|
||||
|
||||
#### 1. Клонирование репозитория
|
||||
|
||||
@@ -42,12 +46,14 @@ cd bedolaga-cabinet
|
||||
|
||||
#### 2. Настройка окружения
|
||||
|
||||
Скопируйте `.env.example` в `.env` и настройте переменные:
|
||||
**⚠️ ОБЯЗАТЕЛЬНО:** Скопируйте `.env.example` в `.env` перед запуском!
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Docker Compose не запустится без `.env` файла.
|
||||
|
||||
**Основные переменные:**
|
||||
|
||||
```env
|
||||
@@ -94,31 +100,134 @@ CABINET_ALLOWED_ORIGINS=http://localhost:3000,https://cabinet.yourdomain.com
|
||||
|
||||
## Настройка прокси для production
|
||||
|
||||
Frontend раздает только статические файлы. Для работы с API нужно настроить reverse proxy, который будет проксировать запросы `/api/*` на backend бота.
|
||||
Frontend - это статические файлы (HTML, JS, CSS). Для работы нужно:
|
||||
1. Раздавать статику через веб-сервер
|
||||
2. Проксировать `/api/*` запросы на backend бота
|
||||
|
||||
### Вариант 1: Caddy
|
||||
> **💡 Важно:** Docker контейнер из этого репозитория содержит nginx, который слушает на **внутреннем порту 80**.
|
||||
> Это НЕ хост-порт! Выберите один из вариантов ниже в зависимости от вашей инфраструктуры.
|
||||
|
||||
Добавьте в ваш Caddyfile:
|
||||
### Вариант 1: Caddy раздает статику напрямую
|
||||
|
||||
**✅ Рекомендуется** - без лишних слоев прокси, максимальная производительность.
|
||||
|
||||
Соберите frontend и примонтируйте в Caddy:
|
||||
|
||||
```bash
|
||||
# Соберите образ или скопируйте dist из контейнера
|
||||
docker compose build
|
||||
docker create --name temp_cabinet cabinet_frontend
|
||||
docker cp temp_cabinet:/usr/share/nginx/html ./cabinet-dist
|
||||
docker rm temp_cabinet
|
||||
```
|
||||
|
||||
Caddyfile:
|
||||
|
||||
```caddyfile
|
||||
cabinet.yourdomain.com {
|
||||
# Проксировать API запросы на backend
|
||||
root * /srv/cabinet
|
||||
encode gzip
|
||||
|
||||
# API запросы на backend
|
||||
handle /api/* {
|
||||
uri strip_prefix /api
|
||||
reverse_proxy backend_bot:8080
|
||||
}
|
||||
|
||||
# Остальное - на frontend контейнер
|
||||
# Статические файлы
|
||||
handle {
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
docker-compose.yml для Caddy:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ./cabinet-dist:/srv/cabinet:ro
|
||||
- caddy_data:/data
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
networks:
|
||||
- bot_network
|
||||
```
|
||||
|
||||
### Вариант 2: Проксирование на frontend контейнер
|
||||
|
||||
Если хотите использовать готовый Docker контейнер с nginx внутри.
|
||||
|
||||
**⚠️ Важно:**
|
||||
- Порт `80` в примерах - это **внутренний порт контейнера** (nginx внутри), не хост-порт!
|
||||
- Контейнеры должны быть в одной Docker сети для связи друг с другом
|
||||
- Имена контейнеров используются как DNS внутри Docker сети
|
||||
|
||||
#### A. Если у вас УЖЕ запущен Caddy/Nginx в Docker:
|
||||
|
||||
**Шаг 1:** Узнайте имя Docker сети вашего Caddy/Nginx:
|
||||
|
||||
```bash
|
||||
# Посмотреть сети
|
||||
docker network ls
|
||||
|
||||
# Или узнать сеть конкретного контейнера
|
||||
docker inspect <имя_caddy_контейнера> | grep NetworkMode
|
||||
```
|
||||
|
||||
**Шаг 2:** Создайте docker-compose.yml для frontend:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
cabinet-frontend:
|
||||
image: ghcr.io/bedolaga-dev/bedolaga-cabinet:latest
|
||||
container_name: cabinet_frontend
|
||||
restart: unless-stopped
|
||||
# НЕ открываем порты на хосте! Только внутри Docker сети
|
||||
networks:
|
||||
- bot_network
|
||||
|
||||
networks:
|
||||
bot_network:
|
||||
external: true # Используем существующую сеть
|
||||
name: remnawave-bedolaga-telegram-bot_bot_network # Пример для bedolaga bot
|
||||
```
|
||||
|
||||
**Важно:** Замените имя сети на вашу:
|
||||
- Если у вас bot + caddy: используйте сеть бота (обычно `<название_проекта>_bot_network`)
|
||||
- Если отдельный Caddy: узнайте через `docker network ls`
|
||||
- Если используете Traefik: обычно `traefik` или `web`
|
||||
|
||||
**Шаг 3:** Запустите frontend:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**Шаг 4:** Добавьте в конфигурацию Caddy/Nginx:
|
||||
|
||||
Caddy проксирует на контейнер:
|
||||
```caddyfile
|
||||
cabinet.yourdomain.com {
|
||||
# API на backend
|
||||
handle /api/* {
|
||||
uri strip_prefix /api
|
||||
reverse_proxy backend_bot:8080
|
||||
}
|
||||
|
||||
# Frontend контейнер (nginx внутри на порту 80)
|
||||
handle {
|
||||
reverse_proxy cabinet_frontend:80
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Вариант 2: Nginx
|
||||
|
||||
Добавьте в конфигурацию Nginx:
|
||||
|
||||
Nginx (добавьте в существующий конфиг):
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
@@ -127,56 +236,6 @@ server {
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
# API запросы проксируем на backend
|
||||
location /api/ {
|
||||
rewrite ^/api/(.*) /$1 break;
|
||||
proxy_pass http://backend_bot:8080;
|
||||
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;
|
||||
}
|
||||
|
||||
# Frontend контейнер
|
||||
location / {
|
||||
proxy_pass http://cabinet_frontend:80;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Вариант 3: Статика + прямое проксирование
|
||||
|
||||
Если хотите раздавать статику напрямую без Docker:
|
||||
|
||||
```bash
|
||||
# Соберите проект
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
# Скопируйте dist на сервер
|
||||
scp -r dist/* user@server:/var/www/cabinet/
|
||||
```
|
||||
|
||||
Конфигурация Nginx для статики:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name cabinet.yourdomain.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
root /var/www/cabinet;
|
||||
index index.html;
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
|
||||
# API на backend
|
||||
location /api/ {
|
||||
rewrite ^/api/(.*) /$1 break;
|
||||
@@ -188,54 +247,46 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# SPA routing
|
||||
# Frontend контейнер (nginx внутри на порту 80)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Кэширование статики
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
proxy_pass http://cabinet_frontend:80;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Разработка
|
||||
|
||||
### Установка зависимостей
|
||||
**Шаг 5:** Перезагрузите Caddy/Nginx:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# Для Caddy
|
||||
docker exec <caddy_container> caddy reload --config /etc/caddy/Caddyfile
|
||||
|
||||
# Для Nginx
|
||||
docker exec <nginx_container> nginx -s reload
|
||||
```
|
||||
|
||||
### Запуск dev сервера
|
||||
#### B. Если Caddy/Nginx ещё НЕ запущен:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
docker-compose.yml:
|
||||
```yaml
|
||||
services:
|
||||
cabinet-frontend:
|
||||
image: ghcr.io/bedolaga-dev/bedolaga-cabinet:latest
|
||||
container_name: cabinet_frontend
|
||||
restart: unless-stopped
|
||||
# Можно открыть порт для прямого доступа (для тестирования)
|
||||
# ports:
|
||||
# - "3000:80"
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
Откроется на `http://localhost:5173`
|
||||
|
||||
### Сборка для production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Результат в папке `dist/`
|
||||
|
||||
### Проверка типов
|
||||
|
||||
```bash
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
### Линтинг
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
Затем настройте Caddy/Nginx в той же сети `web`.
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
@@ -290,6 +341,30 @@ bedolaga-cabinet/
|
||||
2. Контейнеры находятся в одной Docker сети
|
||||
3. Имя сервиса backend в прокси конфигурации правильное
|
||||
|
||||
**Проверка связности контейнеров:**
|
||||
|
||||
```bash
|
||||
# Проверить что frontend доступен из Caddy/Nginx контейнера
|
||||
docker exec <caddy_container> wget -qO- http://cabinet_frontend:80
|
||||
|
||||
# Проверить что backend доступен
|
||||
docker exec <caddy_container> wget -qO- http://backend_bot:8080/health
|
||||
|
||||
# Проверить в какой сети находятся контейнеры
|
||||
docker inspect cabinet_frontend | grep -A 10 Networks
|
||||
docker inspect <caddy_container> | grep -A 10 Networks
|
||||
```
|
||||
|
||||
Если контейнеры в разных сетях, подключите их:
|
||||
|
||||
```bash
|
||||
# Подключить frontend к сети Caddy
|
||||
docker network connect <caddy_network> cabinet_frontend
|
||||
|
||||
# ИЛИ подключить Caddy к сети frontend
|
||||
docker network connect <frontend_network> <caddy_container>
|
||||
```
|
||||
|
||||
### Telegram авторизация не работает
|
||||
|
||||
1. Проверьте `VITE_TELEGRAM_BOT_USERNAME` - должен быть без `@`
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# Docker Compose для локальной разработки/тестирования
|
||||
# Для production используйте готовые образы или настройте Caddy/Nginx
|
||||
# См. README.md раздел "Настройка прокси для production"
|
||||
|
||||
services:
|
||||
cabinet-frontend:
|
||||
build:
|
||||
@@ -11,10 +15,14 @@ services:
|
||||
VITE_APP_LOGO: ${VITE_APP_LOGO:-V}
|
||||
container_name: cabinet_frontend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env # Загрузить переменные из .env файла
|
||||
ports:
|
||||
# Маппинг: <хост-порт>:<контейнер-порт>
|
||||
# Внутри контейнера nginx слушает на порту 80
|
||||
- "${CABINET_PORT:-3000}:80"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/health"]
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
|
||||
@@ -53,6 +53,22 @@ export interface AdminTicketStats {
|
||||
closed: number
|
||||
}
|
||||
|
||||
export interface TicketSettings {
|
||||
sla_enabled: boolean
|
||||
sla_minutes: number
|
||||
sla_check_interval_seconds: number
|
||||
sla_reminder_cooldown_minutes: number
|
||||
support_system_mode: string // tickets, contact, both
|
||||
}
|
||||
|
||||
export interface TicketSettingsUpdate {
|
||||
sla_enabled?: boolean
|
||||
sla_minutes?: number
|
||||
sla_check_interval_seconds?: number
|
||||
sla_reminder_cooldown_minutes?: number
|
||||
support_system_mode?: string
|
||||
}
|
||||
|
||||
export interface AdminTicketListResponse {
|
||||
items: AdminTicket[]
|
||||
total: number
|
||||
@@ -108,6 +124,18 @@ export const adminApi = {
|
||||
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/priority`, { priority })
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get ticket settings
|
||||
getTicketSettings: async (): Promise<TicketSettings> => {
|
||||
const response = await apiClient.get('/cabinet/admin/tickets/settings')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Update ticket settings
|
||||
updateTicketSettings: async (settings: TicketSettingsUpdate): Promise<TicketSettings> => {
|
||||
const response = await apiClient.patch('/cabinet/admin/tickets/settings', settings)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
// ============ Dashboard Stats Types ============
|
||||
@@ -169,12 +197,28 @@ export interface ServerStats {
|
||||
total_revenue_rubles: number
|
||||
}
|
||||
|
||||
export interface TariffStatItem {
|
||||
tariff_id: number
|
||||
tariff_name: string
|
||||
active_subscriptions: number
|
||||
trial_subscriptions: number
|
||||
purchased_today: number
|
||||
purchased_week: number
|
||||
purchased_month: number
|
||||
}
|
||||
|
||||
export interface TariffStats {
|
||||
tariffs: TariffStatItem[]
|
||||
total_tariff_subscriptions: number
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
nodes: NodesOverview
|
||||
subscriptions: SubscriptionStats
|
||||
financial: FinancialStats
|
||||
servers: ServerStats
|
||||
revenue_chart: RevenueData[]
|
||||
tariff_stats?: TariffStats
|
||||
}
|
||||
|
||||
// ============ Dashboard Stats API ============
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface LocalizedText {
|
||||
ru: string
|
||||
zh?: string
|
||||
fa?: string
|
||||
fr?: string
|
||||
}
|
||||
|
||||
export interface AppButton {
|
||||
@@ -116,4 +117,330 @@ export const adminAppsApi = {
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get RemnaWave config status
|
||||
getRemnaWaveStatus: async (): Promise<{ enabled: boolean; config_uuid: string | null }> => {
|
||||
const response = await apiClient.get<{ enabled: boolean; config_uuid: string | null }>(
|
||||
'/cabinet/admin/apps/remnawave/status'
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Set RemnaWave config UUID
|
||||
setRemnaWaveUuid: async (uuid: string | null): Promise<{ enabled: boolean; config_uuid: string | null }> => {
|
||||
const response = await apiClient.put<{ enabled: boolean; config_uuid: string | null }>(
|
||||
'/cabinet/admin/apps/remnawave/uuid',
|
||||
{ uuid }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// List available RemnaWave configs
|
||||
listRemnaWaveConfigs: async (): Promise<{ uuid: string; name: string; view_position: number }[]> => {
|
||||
const response = await apiClient.get<{ uuid: string; name: string; view_position: number }[]>(
|
||||
'/cabinet/admin/apps/remnawave/configs'
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Get RemnaWave subscription config
|
||||
getRemnaWaveConfig: async (): Promise<RemnawaveConfig> => {
|
||||
const response = await apiClient.get<{
|
||||
uuid: string
|
||||
name: string
|
||||
view_position: number
|
||||
config: RemnawaveConfig
|
||||
}>('/cabinet/admin/apps/remnawave/config')
|
||||
return response.data.config
|
||||
},
|
||||
}
|
||||
|
||||
// ============== RemnaWave Format Types ==============
|
||||
|
||||
export interface RemnawaveButton {
|
||||
url: string
|
||||
text: LocalizedText
|
||||
}
|
||||
|
||||
export interface RemnawaveBlock {
|
||||
title: LocalizedText
|
||||
description: LocalizedText
|
||||
buttons?: RemnawaveButton[]
|
||||
svgIconKey?: string
|
||||
svgIconColor?: string
|
||||
}
|
||||
|
||||
export interface RemnawaveApp {
|
||||
name: string
|
||||
featured?: boolean
|
||||
urlScheme?: string
|
||||
isNeedBase64Encoding?: boolean
|
||||
blocks: RemnawaveBlock[]
|
||||
}
|
||||
|
||||
export interface RemnawavePlatform {
|
||||
apps: RemnawaveApp[]
|
||||
}
|
||||
|
||||
export interface RemnawaveSvgItem {
|
||||
svgString: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface RemnawaveBaseSettings {
|
||||
isShowTutorialButton: boolean
|
||||
tutorialUrl: string
|
||||
}
|
||||
|
||||
export interface RemnawaveBaseTranslations {
|
||||
installApp: LocalizedText
|
||||
addSubscription: LocalizedText
|
||||
connectAndUse: LocalizedText
|
||||
copyLink: LocalizedText
|
||||
openApp: LocalizedText
|
||||
tutorial: LocalizedText
|
||||
close: LocalizedText
|
||||
}
|
||||
|
||||
export interface RemnawaveBrandingSettings {
|
||||
name: string
|
||||
logoUrl: string
|
||||
supportUrl: string
|
||||
}
|
||||
|
||||
export interface RemnawaveConfig {
|
||||
platforms: Record<string, RemnawavePlatform>
|
||||
svgLibrary?: Record<string, RemnawaveSvgItem>
|
||||
baseSettings?: RemnawaveBaseSettings
|
||||
baseTranslations?: RemnawaveBaseTranslations
|
||||
brandingSettings?: RemnawaveBrandingSettings
|
||||
}
|
||||
|
||||
// ============== Converter Functions ==============
|
||||
|
||||
const emptyLocalizedText = (): LocalizedText => ({
|
||||
en: '',
|
||||
ru: '',
|
||||
zh: '',
|
||||
fa: '',
|
||||
fr: '',
|
||||
})
|
||||
|
||||
// Convert Cabinet button to RemnaWave button
|
||||
const cabinetButtonToRemnawave = (button: AppButton): RemnawaveButton => ({
|
||||
url: button.buttonLink,
|
||||
text: { ...emptyLocalizedText(), ...button.buttonText },
|
||||
})
|
||||
|
||||
// Convert RemnaWave button to Cabinet button
|
||||
const remnawaveButtonToCabinet = (button: RemnawaveButton): AppButton => ({
|
||||
buttonLink: button.url,
|
||||
buttonText: { en: button.text.en || '', ru: button.text.ru || '', zh: button.text.zh, fa: button.text.fa, fr: button.text.fr },
|
||||
})
|
||||
|
||||
// Convert Cabinet app to RemnaWave app format
|
||||
export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
|
||||
const blocks: RemnawaveBlock[] = []
|
||||
|
||||
// Block 1: Installation
|
||||
blocks.push({
|
||||
title: { ...emptyLocalizedText(), en: 'Install App', ru: 'Установка приложения' },
|
||||
description: { ...emptyLocalizedText(), ...app.installationStep.description },
|
||||
buttons: app.installationStep.buttons?.map(cabinetButtonToRemnawave),
|
||||
svgIconKey: 'download',
|
||||
svgIconColor: '#3B82F6',
|
||||
})
|
||||
|
||||
// Block 2 (optional): Additional before subscription
|
||||
if (app.additionalBeforeAddSubscriptionStep?.description?.en || app.additionalBeforeAddSubscriptionStep?.description?.ru) {
|
||||
blocks.push({
|
||||
title: app.additionalBeforeAddSubscriptionStep.title || { ...emptyLocalizedText(), en: 'Preparation', ru: 'Подготовка' },
|
||||
description: { ...emptyLocalizedText(), ...app.additionalBeforeAddSubscriptionStep.description },
|
||||
buttons: app.additionalBeforeAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
|
||||
svgIconKey: 'settings',
|
||||
svgIconColor: '#8B5CF6',
|
||||
})
|
||||
}
|
||||
|
||||
// Block 3: Add subscription
|
||||
blocks.push({
|
||||
title: { ...emptyLocalizedText(), en: 'Add Subscription', ru: 'Добавить подписку' },
|
||||
description: { ...emptyLocalizedText(), ...app.addSubscriptionStep.description },
|
||||
buttons: app.addSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
|
||||
svgIconKey: 'plus',
|
||||
svgIconColor: '#10B981',
|
||||
})
|
||||
|
||||
// Block 4 (optional): Additional after subscription
|
||||
if (app.additionalAfterAddSubscriptionStep?.description?.en || app.additionalAfterAddSubscriptionStep?.description?.ru) {
|
||||
blocks.push({
|
||||
title: app.additionalAfterAddSubscriptionStep.title || { ...emptyLocalizedText(), en: 'Configuration', ru: 'Настройка' },
|
||||
description: { ...emptyLocalizedText(), ...app.additionalAfterAddSubscriptionStep.description },
|
||||
buttons: app.additionalAfterAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
|
||||
svgIconKey: 'settings',
|
||||
svgIconColor: '#F59E0B',
|
||||
})
|
||||
}
|
||||
|
||||
// Block 5: Connect and use
|
||||
blocks.push({
|
||||
title: { ...emptyLocalizedText(), en: 'Connect and Use', ru: 'Подключение и использование' },
|
||||
description: { ...emptyLocalizedText(), ...app.connectAndUseStep.description },
|
||||
buttons: app.connectAndUseStep.buttons?.map(cabinetButtonToRemnawave),
|
||||
svgIconKey: 'check',
|
||||
svgIconColor: '#22C55E',
|
||||
})
|
||||
|
||||
return {
|
||||
name: app.name,
|
||||
featured: app.isFeatured,
|
||||
urlScheme: app.urlScheme,
|
||||
isNeedBase64Encoding: app.isNeedBase64Encoding,
|
||||
blocks,
|
||||
}
|
||||
}
|
||||
|
||||
// Convert RemnaWave app to Cabinet app format
|
||||
export const remnawaveAppToCabinet = (app: RemnawaveApp, platform: string): AppDefinition => {
|
||||
const blocks = app.blocks || []
|
||||
|
||||
// Default empty step
|
||||
const emptyStep = (): AppStep => ({
|
||||
description: emptyLocalizedText(),
|
||||
buttons: [],
|
||||
})
|
||||
|
||||
// Try to identify blocks by their titles or position
|
||||
let installationBlock: RemnawaveBlock | undefined
|
||||
let subscriptionBlock: RemnawaveBlock | undefined
|
||||
let connectBlock: RemnawaveBlock | undefined
|
||||
let beforeSubBlock: RemnawaveBlock | undefined
|
||||
let afterSubBlock: RemnawaveBlock | undefined
|
||||
|
||||
// First pass: try to identify by title keywords
|
||||
for (const block of blocks) {
|
||||
const enTitle = (block.title?.en || '').toLowerCase()
|
||||
const ruTitle = (block.title?.ru || '').toLowerCase()
|
||||
|
||||
if (enTitle.includes('install') || ruTitle.includes('установ') || ruTitle.includes('скачай')) {
|
||||
if (!installationBlock) installationBlock = block
|
||||
} else if (enTitle.includes('subscription') || enTitle.includes('add') || ruTitle.includes('подписк') || ruTitle.includes('добав')) {
|
||||
if (!subscriptionBlock) subscriptionBlock = block
|
||||
} else if (enTitle.includes('connect') || enTitle.includes('use') || ruTitle.includes('подключ') || ruTitle.includes('использ')) {
|
||||
if (!connectBlock) connectBlock = block
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: assign remaining blocks by position if not found by title
|
||||
if (!installationBlock && blocks.length > 0) {
|
||||
installationBlock = blocks[0]
|
||||
}
|
||||
if (!subscriptionBlock && blocks.length > 1) {
|
||||
subscriptionBlock = blocks.find(b => b !== installationBlock) || blocks[1]
|
||||
}
|
||||
if (!connectBlock && blocks.length > 2) {
|
||||
connectBlock = blocks.find(b => b !== installationBlock && b !== subscriptionBlock) || blocks[blocks.length - 1]
|
||||
}
|
||||
|
||||
// Assign additional blocks
|
||||
const additionalBlocks = blocks.filter(b =>
|
||||
b !== installationBlock && b !== subscriptionBlock && b !== connectBlock
|
||||
)
|
||||
if (additionalBlocks.length >= 1) {
|
||||
// Check if block appears before subscription
|
||||
const subIndex = blocks.indexOf(subscriptionBlock!)
|
||||
const firstAdditionalIndex = blocks.indexOf(additionalBlocks[0])
|
||||
if (firstAdditionalIndex < subIndex) {
|
||||
beforeSubBlock = additionalBlocks[0]
|
||||
if (additionalBlocks.length >= 2) {
|
||||
afterSubBlock = additionalBlocks[1]
|
||||
}
|
||||
} else {
|
||||
afterSubBlock = additionalBlocks[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Convert block to cabinet step
|
||||
const blockToStep = (block: RemnawaveBlock | undefined): AppStep => {
|
||||
if (!block) return emptyStep()
|
||||
return {
|
||||
description: { en: block.description?.en || '', ru: block.description?.ru || '', zh: block.description?.zh, fa: block.description?.fa, fr: block.description?.fr },
|
||||
title: block.title ? { en: block.title.en || '', ru: block.title.ru || '', zh: block.title.zh, fa: block.title.fa, fr: block.title.fr } : undefined,
|
||||
buttons: block.buttons?.map(remnawaveButtonToCabinet),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: `${app.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${platform}-${Date.now()}`,
|
||||
name: app.name,
|
||||
isFeatured: app.featured || false,
|
||||
urlScheme: app.urlScheme || '',
|
||||
isNeedBase64Encoding: app.isNeedBase64Encoding,
|
||||
installationStep: blockToStep(installationBlock),
|
||||
addSubscriptionStep: blockToStep(subscriptionBlock),
|
||||
connectAndUseStep: blockToStep(connectBlock),
|
||||
additionalBeforeAddSubscriptionStep: beforeSubBlock ? blockToStep(beforeSubBlock) : undefined,
|
||||
additionalAfterAddSubscriptionStep: afterSubBlock ? blockToStep(afterSubBlock) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Export all apps from cabinet to RemnaWave format
|
||||
export const exportToRemnawaveFormat = (
|
||||
platformApps: Record<string, AppDefinition[]>,
|
||||
branding?: AppConfigBranding
|
||||
): RemnawaveConfig => {
|
||||
const platforms: Record<string, RemnawavePlatform> = {}
|
||||
|
||||
for (const [platform, apps] of Object.entries(platformApps)) {
|
||||
platforms[platform] = {
|
||||
apps: apps.map(cabinetAppToRemnawave),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
platforms,
|
||||
svgLibrary: {
|
||||
download: { svgString: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg>' },
|
||||
plus: { svgString: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>' },
|
||||
check: { svgString: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg>' },
|
||||
settings: { svgString: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>' },
|
||||
},
|
||||
baseSettings: {
|
||||
isShowTutorialButton: false,
|
||||
tutorialUrl: '',
|
||||
},
|
||||
baseTranslations: {
|
||||
installApp: { en: 'Install App', ru: 'Установить приложение', zh: '安装应用', fa: 'نصب برنامه', fr: 'Installer l\'application' },
|
||||
addSubscription: { en: 'Add Subscription', ru: 'Добавить подписку', zh: '添加订阅', fa: 'اضافه کردن اشتراک', fr: 'Ajouter un abonnement' },
|
||||
connectAndUse: { en: 'Connect and Use', ru: 'Подключиться и использовать', zh: '连接并使用', fa: 'اتصال و استفاده', fr: 'Connecter et utiliser' },
|
||||
copyLink: { en: 'Copy Link', ru: 'Скопировать ссылку', zh: '复制链接', fa: 'کپی لینک', fr: 'Copier le lien' },
|
||||
openApp: { en: 'Open App', ru: 'Открыть приложение', zh: '打开应用', fa: 'باز کردن برنامه', fr: 'Ouvrir l\'application' },
|
||||
tutorial: { en: 'Tutorial', ru: 'Инструкция', zh: '教程', fa: 'آموزش', fr: 'Tutoriel' },
|
||||
close: { en: 'Close', ru: 'Закрыть', zh: '关闭', fa: 'بستن', fr: 'Fermer' },
|
||||
},
|
||||
brandingSettings: branding ? {
|
||||
name: branding.name,
|
||||
logoUrl: branding.logoUrl,
|
||||
supportUrl: branding.supportUrl,
|
||||
} : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Import apps from RemnaWave format to cabinet
|
||||
export const importFromRemnawaveFormat = (
|
||||
config: RemnawaveConfig
|
||||
): { platformApps: Record<string, AppDefinition[]>; branding?: AppConfigBranding } => {
|
||||
const platformApps: Record<string, AppDefinition[]> = {}
|
||||
|
||||
for (const [platform, platformData] of Object.entries(config.platforms || {})) {
|
||||
platformApps[platform] = (platformData.apps || []).map(app => remnawaveAppToCabinet(app, platform))
|
||||
}
|
||||
|
||||
const branding = config.brandingSettings ? {
|
||||
name: config.brandingSettings.name,
|
||||
logoUrl: config.brandingSettings.logoUrl,
|
||||
supportUrl: config.brandingSettings.supportUrl,
|
||||
} : undefined
|
||||
|
||||
return { platformApps, branding }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import apiClient from './client'
|
||||
import apiClient from './client'
|
||||
import type { Balance, Transaction, PaymentMethod, PaginatedResponse } from '../types'
|
||||
|
||||
export const balanceApi = {
|
||||
@@ -27,7 +27,7 @@ export const balanceApi = {
|
||||
},
|
||||
|
||||
// Create top-up payment
|
||||
createTopUp: async (amountKopeks: number, paymentMethod: string): Promise<{
|
||||
createTopUp: async (amountKopeks: number, paymentMethod: string, paymentOption?: string): Promise<{
|
||||
payment_id: string
|
||||
payment_url: string
|
||||
amount_kopeks: number
|
||||
@@ -35,10 +35,18 @@ export const balanceApi = {
|
||||
status: string
|
||||
expires_at: string | null
|
||||
}> => {
|
||||
const response = await apiClient.post('/cabinet/balance/topup', {
|
||||
const payload: {
|
||||
amount_kopeks: number
|
||||
payment_method: string
|
||||
payment_option?: string
|
||||
} = {
|
||||
amount_kopeks: amountKopeks,
|
||||
payment_method: paymentMethod,
|
||||
})
|
||||
}
|
||||
if (paymentOption) {
|
||||
payload.payment_option = paymentOption
|
||||
}
|
||||
const response = await apiClient.post('/cabinet/balance/topup', payload)
|
||||
return response.data
|
||||
},
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface TariffListItem {
|
||||
description: string | null
|
||||
is_active: boolean
|
||||
is_trial_available: boolean
|
||||
is_daily: boolean
|
||||
daily_price_kopeks: number
|
||||
traffic_limit_gb: number
|
||||
device_limit: number
|
||||
tier_level: number
|
||||
@@ -55,6 +57,7 @@ export interface TariffDetail {
|
||||
traffic_limit_gb: number
|
||||
device_limit: number
|
||||
device_price_kopeks: number | null
|
||||
max_device_limit: number | null
|
||||
tier_level: number
|
||||
display_order: number
|
||||
period_prices: PeriodPrice[]
|
||||
@@ -91,6 +94,7 @@ export interface TariffCreateRequest {
|
||||
traffic_limit_gb?: number
|
||||
device_limit?: number
|
||||
device_price_kopeks?: number
|
||||
max_device_limit?: number
|
||||
tier_level?: number
|
||||
period_prices?: PeriodPrice[]
|
||||
allowed_squads?: string[]
|
||||
@@ -122,6 +126,7 @@ export interface TariffUpdateRequest {
|
||||
traffic_limit_gb?: number
|
||||
device_limit?: number
|
||||
device_price_kopeks?: number
|
||||
max_device_limit?: number
|
||||
tier_level?: number
|
||||
display_order?: number
|
||||
period_prices?: PeriodPrice[]
|
||||
|
||||
@@ -53,8 +53,13 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) {
|
||||
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency()
|
||||
const [amount, setAmount] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedOption, setSelectedOption] = useState<string | null>(
|
||||
method.options && method.options.length > 0 ? method.options[0].id : null
|
||||
)
|
||||
const popupRef = useRef<Window | null>(null)
|
||||
|
||||
const hasOptions = method.options && method.options.length > 0
|
||||
|
||||
const minRubles = method.min_amount_kopeks / 100
|
||||
const maxRubles = method.max_amount_kopeks / 100
|
||||
|
||||
@@ -121,7 +126,7 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) {
|
||||
status: string
|
||||
expires_at: string | null
|
||||
}, unknown, number>({
|
||||
mutationFn: (amountKopeks: number) => balanceApi.createTopUp(amountKopeks, method.id),
|
||||
mutationFn: (amountKopeks: number) => balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined),
|
||||
onSuccess: (data) => {
|
||||
const redirectUrl = data.payment_url || (data as any).invoice_url
|
||||
if (redirectUrl) {
|
||||
@@ -141,6 +146,7 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) {
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault(); setError(null)
|
||||
if (hasOptions && !selectedOption) { setError(t('balance.selectPaymentOption', 'Выберите способ оплаты')); return }
|
||||
const amountCurrency = parseFloat(amount)
|
||||
if (isNaN(amountCurrency) || amountCurrency <= 0) { setError(t('balance.invalidAmount', 'Invalid amount')); return }
|
||||
const amountRubles = convertToRub(amountCurrency)
|
||||
@@ -180,6 +186,33 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) {
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{hasOptions && method.options && (
|
||||
<div>
|
||||
<label className="label">{t('balance.paymentOption', 'Способ оплаты')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{method.options.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedOption(option.id)}
|
||||
className={`px-4 py-2.5 rounded-xl text-sm font-medium transition-all flex flex-col items-start ${
|
||||
selectedOption === option.id
|
||||
? 'bg-accent-500 text-white ring-2 ring-accent-400 ring-offset-2 ring-offset-dark-900'
|
||||
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
<span>{option.name}</span>
|
||||
{option.description && (
|
||||
<span className={`text-xs mt-0.5 ${selectedOption === option.id ? 'text-white/70' : 'text-dark-500'}`}>
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="label">{t('balance.amount')} ({currencySymbol})</label>
|
||||
<input
|
||||
|
||||
@@ -149,9 +149,11 @@ export default function Layout({ children }: LayoutProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Lock body scroll when mobile menu is open
|
||||
// Lock body scroll and scroll to top when mobile menu is open
|
||||
useEffect(() => {
|
||||
if (mobileMenuOpen) {
|
||||
// Scroll to top so header is visible
|
||||
window.scrollTo({ top: 0, behavior: 'instant' })
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
|
||||
@@ -218,7 +218,9 @@
|
||||
"newPackage": "New package",
|
||||
"switch": "Change",
|
||||
"switched": "Traffic changed"
|
||||
}
|
||||
},
|
||||
"purchasedTraffic": "Purchased Traffic",
|
||||
"trafficResetAt": "Reset"
|
||||
},
|
||||
"balance": {
|
||||
"title": "Balance",
|
||||
@@ -260,6 +262,8 @@
|
||||
},
|
||||
"minAmountError": "Minimum amount: {{amount}} ₽",
|
||||
"maxAmountError": "Maximum amount: {{amount}} ₽",
|
||||
"paymentOption": "Payment option",
|
||||
"selectPaymentOption": "Select payment option",
|
||||
"paymentMethods": {
|
||||
"yookassa": {
|
||||
"name": "YooKassa (Bank Card)",
|
||||
@@ -272,6 +276,34 @@
|
||||
"telegram_stars": {
|
||||
"name": "Telegram Stars",
|
||||
"description": "Pay with Telegram Stars"
|
||||
},
|
||||
"heleket": {
|
||||
"name": "Heleket Crypto",
|
||||
"description": "Pay with cryptocurrency via Heleket"
|
||||
},
|
||||
"mulenpay": {
|
||||
"name": "MulenPay",
|
||||
"description": "Pay via MulenPay"
|
||||
},
|
||||
"pal24": {
|
||||
"name": "PAL24",
|
||||
"description": "Pay via PAL24"
|
||||
},
|
||||
"platega": {
|
||||
"name": "Platega",
|
||||
"description": "Pay via Platega"
|
||||
},
|
||||
"wata": {
|
||||
"name": "Wata",
|
||||
"description": "Pay via Wata"
|
||||
},
|
||||
"cloudpayments": {
|
||||
"name": "CloudPayments",
|
||||
"description": "Pay with bank card via CloudPayments"
|
||||
},
|
||||
"freekassa": {
|
||||
"name": "FreeKassa",
|
||||
"description": "Pay via FreeKassa"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -301,8 +333,19 @@
|
||||
"user": "User",
|
||||
"joined": "Joined",
|
||||
"hasPaid": "Has Paid",
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"paid": "Paid"
|
||||
},
|
||||
"earningsHistory": "Earnings History",
|
||||
"reason": "Reason"
|
||||
"reason": "Reason",
|
||||
"reasons": {
|
||||
"referral_registration_pending": "Referral Registration",
|
||||
"referral_registration": "Referral Registration",
|
||||
"referral_first_payment": "Referral First Payment",
|
||||
"referral_subscription_renewal": "Referral Subscription Renewal",
|
||||
"referral_bonus": "Referral Bonus"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
"title": "Support",
|
||||
@@ -514,7 +557,23 @@
|
||||
"subscription": "Subscription",
|
||||
"connect": "Connect",
|
||||
"additional": "Additional"
|
||||
}
|
||||
},
|
||||
"import": "Import",
|
||||
"export": "Export",
|
||||
"importApps": "Import apps from JSON",
|
||||
"exportApps": "Export apps to JSON",
|
||||
"importPreview": "Import Preview",
|
||||
"confirmImport": "Import Apps",
|
||||
"importError": "Invalid JSON file format",
|
||||
"importCreateError": "Failed to create some apps",
|
||||
"noAppsToImport": "No apps to import",
|
||||
"remnaWaveToggle": "Toggle RemnaWave source",
|
||||
"refreshRemnaWave": "Refresh from RemnaWave",
|
||||
"remnaWaveMode": "Showing apps from RemnaWave panel. Read-only mode.",
|
||||
"remnaWaveSettings": "RemnaWave Settings",
|
||||
"remnaWaveConfigUuid": "Config UUID",
|
||||
"remnaWaveConfigUuidHint": "UUID of subscription page config from RemnaWave panel",
|
||||
"availableConfigs": "Available configs"
|
||||
},
|
||||
"tickets": {
|
||||
"title": "Ticket Management",
|
||||
@@ -534,7 +593,23 @@
|
||||
"userLabel": "User",
|
||||
"adminLabel": "Admin",
|
||||
"replyPlaceholder": "Type your reply...",
|
||||
"sendReply": "Send Reply"
|
||||
"sendReply": "Send Reply",
|
||||
"settings": "Settings",
|
||||
"supportMode": "Support mode",
|
||||
"supportModeDesc": "Choose how users can contact support",
|
||||
"modeBoth": "Tickets and contact",
|
||||
"modeTickets": "Tickets only",
|
||||
"modeContact": "Contact only",
|
||||
"slaSettings": "SLA Settings",
|
||||
"slaEnabled": "Enable SLA (response time monitoring)",
|
||||
"slaEnabledDesc": "Automatic notifications for overdue tickets",
|
||||
"slaMinutes": "Response time (minutes)",
|
||||
"slaMinutesDesc": "Maximum time to respond to a ticket (1-1440 minutes)",
|
||||
"checkInterval": "Check interval (seconds)",
|
||||
"checkIntervalDesc": "How often to check for overdue tickets (30-600 seconds)",
|
||||
"reminderCooldown": "Reminder interval (minutes)",
|
||||
"reminderCooldownDesc": "Minimum time between reminders (1-120 minutes)",
|
||||
"settingsUpdateError": "Error saving settings"
|
||||
},
|
||||
"tariffs": {
|
||||
"title": "Tariff Management",
|
||||
@@ -656,6 +731,17 @@
|
||||
"available": "Available",
|
||||
"withConnections": "With Connections",
|
||||
"revenue": "Revenue"
|
||||
},
|
||||
"tariffs": {
|
||||
"title": "Tariff Statistics",
|
||||
"subtitle": "Subscription distribution by tariffs",
|
||||
"tariffName": "Tariff",
|
||||
"activeSubscriptions": "Active",
|
||||
"trialSubscriptions": "Trial",
|
||||
"purchasedToday": "Today",
|
||||
"purchasedWeek": "Week",
|
||||
"purchasedMonth": "Month",
|
||||
"noTariffs": "No tariffs"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
|
||||
1447
src/locales/fa.json
1447
src/locales/fa.json
File diff suppressed because it is too large
Load Diff
@@ -218,7 +218,9 @@
|
||||
"newPackage": "Новый пакет",
|
||||
"switch": "Изменить",
|
||||
"switched": "Трафик изменён"
|
||||
}
|
||||
},
|
||||
"purchasedTraffic": "Докупленный трафик",
|
||||
"trafficResetAt": "Сброс"
|
||||
},
|
||||
"balance": {
|
||||
"title": "Баланс",
|
||||
@@ -260,6 +262,8 @@
|
||||
},
|
||||
"minAmountError": "Минимальная сумма: {{amount}} ₽",
|
||||
"maxAmountError": "Максимальная сумма: {{amount}} ₽",
|
||||
"paymentOption": "Способ оплаты",
|
||||
"selectPaymentOption": "Выберите способ оплаты",
|
||||
"paymentMethods": {
|
||||
"yookassa": {
|
||||
"name": "ЮKassa (Банковская карта)",
|
||||
@@ -272,6 +276,34 @@
|
||||
"telegram_stars": {
|
||||
"name": "Telegram Stars",
|
||||
"description": "Оплата через Telegram Stars"
|
||||
},
|
||||
"heleket": {
|
||||
"name": "Heleket Crypto",
|
||||
"description": "Оплата криптовалютой через Heleket"
|
||||
},
|
||||
"mulenpay": {
|
||||
"name": "MulenPay",
|
||||
"description": "Оплата через MulenPay"
|
||||
},
|
||||
"pal24": {
|
||||
"name": "PAL24",
|
||||
"description": "Оплата через PAL24"
|
||||
},
|
||||
"platega": {
|
||||
"name": "Platega",
|
||||
"description": "Оплата через Platega"
|
||||
},
|
||||
"wata": {
|
||||
"name": "Wata",
|
||||
"description": "Оплата через Wata"
|
||||
},
|
||||
"cloudpayments": {
|
||||
"name": "CloudPayments",
|
||||
"description": "Оплата банковской картой через CloudPayments"
|
||||
},
|
||||
"freekassa": {
|
||||
"name": "FreeKassa",
|
||||
"description": "Оплата через FreeKassa"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -301,8 +333,19 @@
|
||||
"user": "Пользователь",
|
||||
"joined": "Присоединился",
|
||||
"hasPaid": "Оплатил",
|
||||
"status": {
|
||||
"pending": "Ожидание",
|
||||
"paid": "Оплачено"
|
||||
},
|
||||
"earningsHistory": "История начислений",
|
||||
"reason": "Причина"
|
||||
"reason": "Причина",
|
||||
"reasons": {
|
||||
"referral_registration_pending": "Регистрация реферала",
|
||||
"referral_registration": "Регистрация реферала",
|
||||
"referral_first_payment": "Первая оплата реферала",
|
||||
"referral_subscription_renewal": "Продление подписки реферала",
|
||||
"referral_bonus": "Бонус за реферала"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
"title": "Поддержка",
|
||||
@@ -514,7 +557,23 @@
|
||||
"subscription": "Подписка",
|
||||
"connect": "Подключение",
|
||||
"additional": "Дополнительно"
|
||||
}
|
||||
},
|
||||
"import": "Импорт",
|
||||
"export": "Экспорт",
|
||||
"importApps": "Импорт приложений из JSON",
|
||||
"exportApps": "Экспорт приложений в JSON",
|
||||
"importPreview": "Предпросмотр импорта",
|
||||
"confirmImport": "Импортировать",
|
||||
"importError": "Неверный формат JSON файла",
|
||||
"importCreateError": "Ошибка при создании приложений",
|
||||
"noAppsToImport": "Нет приложений для импорта",
|
||||
"remnaWaveToggle": "Переключить источник RemnaWave",
|
||||
"refreshRemnaWave": "Обновить данные из RemnaWave",
|
||||
"remnaWaveMode": "Отображаются приложения из панели RemnaWave. Режим только для чтения.",
|
||||
"remnaWaveSettings": "Настройки RemnaWave",
|
||||
"remnaWaveConfigUuid": "UUID конфигурации",
|
||||
"remnaWaveConfigUuidHint": "UUID конфигурации страницы подписки из панели RemnaWave",
|
||||
"availableConfigs": "Доступные конфигурации"
|
||||
},
|
||||
"tickets": {
|
||||
"title": "Управление тикетами",
|
||||
@@ -534,7 +593,23 @@
|
||||
"userLabel": "Пользователь",
|
||||
"adminLabel": "Админ",
|
||||
"replyPlaceholder": "Введите ваш ответ...",
|
||||
"sendReply": "Отправить"
|
||||
"sendReply": "Отправить",
|
||||
"settings": "Настройки",
|
||||
"supportMode": "Режим поддержки",
|
||||
"supportModeDesc": "Выберите, как пользователи могут связаться с поддержкой",
|
||||
"modeBoth": "Тикеты и контакт",
|
||||
"modeTickets": "Только тикеты",
|
||||
"modeContact": "Только контакт",
|
||||
"slaSettings": "Настройки SLA",
|
||||
"slaEnabled": "Включить SLA (мониторинг времени ответа)",
|
||||
"slaEnabledDesc": "Автоматические уведомления о просроченных тикетах",
|
||||
"slaMinutes": "Время ответа (минуты)",
|
||||
"slaMinutesDesc": "Максимальное время для ответа на тикет (1-1440 минут)",
|
||||
"checkInterval": "Интервал проверки (секунды)",
|
||||
"checkIntervalDesc": "Как часто проверять просроченные тикеты (30-600 секунд)",
|
||||
"reminderCooldown": "Интервал напоминаний (минуты)",
|
||||
"reminderCooldownDesc": "Минимальное время между напоминаниями (1-120 минут)",
|
||||
"settingsUpdateError": "Ошибка сохранения настроек"
|
||||
},
|
||||
"tariffs": {
|
||||
"title": "Управление тарифами",
|
||||
@@ -656,6 +731,17 @@
|
||||
"available": "Доступных",
|
||||
"withConnections": "С подключениями",
|
||||
"revenue": "Доход"
|
||||
},
|
||||
"tariffs": {
|
||||
"title": "Статистика по тарифам",
|
||||
"subtitle": "Распределение подписок по тарифам",
|
||||
"tariffName": "Тариф",
|
||||
"activeSubscriptions": "Активных",
|
||||
"trialSubscriptions": "Триал",
|
||||
"purchasedToday": "Сегодня",
|
||||
"purchasedWeek": "Неделя",
|
||||
"purchasedMonth": "Месяц",
|
||||
"noTariffs": "Нет тарифов"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"common": {
|
||||
"loading": "加载中...",
|
||||
"error": "错误",
|
||||
@@ -165,7 +165,20 @@
|
||||
"currentBalance": "当前余额",
|
||||
"topUp": "充值",
|
||||
"topUpBalance": "充值余额",
|
||||
"paymentMethods": "支付方式",
|
||||
"paymentMethods": {
|
||||
"yookassa": {
|
||||
"name": "银行卡",
|
||||
"description": "通过银行卡支付"
|
||||
},
|
||||
"cryptobot": {
|
||||
"name": "CryptoBot",
|
||||
"description": "通过CryptoBot加密货币支付"
|
||||
},
|
||||
"telegram_stars": {
|
||||
"name": "Telegram Stars",
|
||||
"description": "通过Telegram Stars支付"
|
||||
}
|
||||
},
|
||||
"transactionHistory": "交易记录",
|
||||
"noTransactions": "暂无交易",
|
||||
"date": "日期",
|
||||
@@ -199,21 +212,7 @@
|
||||
}
|
||||
},
|
||||
"minAmountError": "最小金额:{{amount}}",
|
||||
"maxAmountError": "最大金额:{{amount}}",
|
||||
"paymentMethods": {
|
||||
"yookassa": {
|
||||
"name": "银行卡",
|
||||
"description": "通过银行卡支付"
|
||||
},
|
||||
"cryptobot": {
|
||||
"name": "CryptoBot",
|
||||
"description": "通过CryptoBot加密货币支付"
|
||||
},
|
||||
"telegram_stars": {
|
||||
"name": "Telegram Stars",
|
||||
"description": "通过Telegram Stars支付"
|
||||
}
|
||||
}
|
||||
"maxAmountError": "最大金额:{{amount}}"
|
||||
},
|
||||
"referral": {
|
||||
"title": "推荐计划",
|
||||
@@ -242,7 +241,18 @@
|
||||
"joined": "加入时间",
|
||||
"hasPaid": "已付款",
|
||||
"earningsHistory": "收益记录",
|
||||
"reason": "原因"
|
||||
"reason": "原因",
|
||||
"status": {
|
||||
"pending": "待定",
|
||||
"paid": "已支付"
|
||||
},
|
||||
"reasons": {
|
||||
"referral_registration_pending": "推荐用户注册",
|
||||
"referral_registration": "推荐用户注册",
|
||||
"referral_first_payment": "推荐用户首次付款",
|
||||
"referral_subscription_renewal": "推荐用户续订",
|
||||
"referral_bonus": "推荐奖金"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
"title": "支持",
|
||||
@@ -462,7 +472,23 @@
|
||||
"userLabel": "用户",
|
||||
"adminLabel": "管理员",
|
||||
"replyPlaceholder": "输入您的回复...",
|
||||
"sendReply": "发送回复"
|
||||
"sendReply": "发送回复",
|
||||
"settings": "设置",
|
||||
"supportMode": "支持模式",
|
||||
"supportModeDesc": "选择用户如何联系支持",
|
||||
"modeBoth": "工单和联系方式",
|
||||
"modeTickets": "仅工单",
|
||||
"modeContact": "仅联系方式",
|
||||
"slaSettings": "SLA设置",
|
||||
"slaEnabled": "启用 SLA(响应时间监控)",
|
||||
"slaEnabledDesc": "自动通知过期工单",
|
||||
"slaMinutes": "响应时间(分钟)",
|
||||
"slaMinutesDesc": "回复工单的最长时间(1-1440分钟)",
|
||||
"checkInterval": "检查间隔(秒)",
|
||||
"checkIntervalDesc": "检查过期工单的频率(30-600秒)",
|
||||
"reminderCooldown": "提醒间隔(分钟)",
|
||||
"reminderCooldownDesc": "提醒之间的最小时间(1-120分钟)",
|
||||
"settingsUpdateError": "保存设置时出错"
|
||||
},
|
||||
"tariffs": {
|
||||
"title": "套餐管理",
|
||||
@@ -709,4 +735,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,17 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { adminAppsApi, AppDefinition, LocalizedText, AppStep, AppButton } from '../api/adminApps'
|
||||
import {
|
||||
adminAppsApi,
|
||||
AppDefinition,
|
||||
LocalizedText,
|
||||
AppStep,
|
||||
AppButton,
|
||||
exportToRemnawaveFormat,
|
||||
importFromRemnawaveFormat,
|
||||
RemnawaveConfig,
|
||||
importFromRemnawaveFormat as convertRemnawave,
|
||||
} from '../api/adminApps'
|
||||
|
||||
// Icons
|
||||
const AppsIcon = () => (
|
||||
@@ -52,6 +62,37 @@ const StarIcon = ({ filled }: { filled: boolean }) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
const DownloadIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const UploadIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CloudSyncIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const RefreshIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SettingsIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.203-.107-.397.165-.71.505-.781.929l-.149.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.505-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const PLATFORM_LABELS: Record<string, string> = {
|
||||
ios: 'iOS',
|
||||
android: 'Android',
|
||||
@@ -389,12 +430,62 @@ export default function AdminApps() {
|
||||
const [selectedPlatform, setSelectedPlatform] = useState<string>('ios')
|
||||
const [editingApp, setEditingApp] = useState<{ app: AppDefinition; isNew: boolean } | null>(null)
|
||||
const [copyTarget, setCopyTarget] = useState<{ appId: string; platform: string } | null>(null)
|
||||
const [showImportModal, setShowImportModal] = useState(false)
|
||||
const [importError, setImportError] = useState<string | null>(null)
|
||||
const [importPreview, setImportPreview] = useState<{ platformApps: Record<string, AppDefinition[]> } | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [useRemnaWave, setUseRemnaWave] = useState(false)
|
||||
const [showRemnaWaveSettings, setShowRemnaWaveSettings] = useState(false)
|
||||
const [remnaWaveUuidInput, setRemnaWaveUuidInput] = useState('')
|
||||
|
||||
const { data: apps, isLoading } = useQuery({
|
||||
// Check if RemnaWave config is available
|
||||
const { data: remnaWaveStatus, refetch: refetchStatus } = useQuery({
|
||||
queryKey: ['remnawave-status'],
|
||||
queryFn: adminAppsApi.getRemnaWaveStatus,
|
||||
staleTime: 60000,
|
||||
})
|
||||
|
||||
// List available RemnaWave configs
|
||||
const { data: availableConfigs, isLoading: isLoadingConfigs } = useQuery({
|
||||
queryKey: ['remnawave-configs-list'],
|
||||
queryFn: adminAppsApi.listRemnaWaveConfigs,
|
||||
enabled: showRemnaWaveSettings,
|
||||
staleTime: 30000,
|
||||
})
|
||||
|
||||
// Mutation for setting UUID
|
||||
const setUuidMutation = useMutation({
|
||||
mutationFn: adminAppsApi.setRemnaWaveUuid,
|
||||
onSuccess: () => {
|
||||
refetchStatus()
|
||||
queryClient.invalidateQueries({ queryKey: ['remnawave-config'] })
|
||||
setShowRemnaWaveSettings(false)
|
||||
},
|
||||
})
|
||||
|
||||
// Fetch RemnaWave config when toggle is enabled
|
||||
const { data: remnaWaveConfig, isLoading: isRemnaWaveLoading, refetch: refetchRemnaWave } = useQuery({
|
||||
queryKey: ['remnawave-config'],
|
||||
queryFn: adminAppsApi.getRemnaWaveConfig,
|
||||
enabled: useRemnaWave && remnaWaveStatus?.enabled,
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
// Convert RemnaWave config to local format
|
||||
const remnaWaveApps = remnaWaveConfig
|
||||
? convertRemnawave(remnaWaveConfig).platformApps[selectedPlatform] || []
|
||||
: []
|
||||
|
||||
const { data: localApps, isLoading: isLocalLoading } = useQuery({
|
||||
queryKey: ['admin-apps', selectedPlatform],
|
||||
queryFn: () => adminAppsApi.getPlatformApps(selectedPlatform),
|
||||
enabled: !useRemnaWave,
|
||||
})
|
||||
|
||||
// Select which apps to show based on toggle
|
||||
const apps = useRemnaWave ? remnaWaveApps : localApps
|
||||
const isLoading = useRemnaWave ? isRemnaWaveLoading : isLocalLoading
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: ({ platform, app }: { platform: string; app: AppDefinition }) =>
|
||||
adminAppsApi.createApp(platform, app),
|
||||
@@ -438,6 +529,85 @@ export default function AdminApps() {
|
||||
},
|
||||
})
|
||||
|
||||
// Fetch all platforms for export
|
||||
const { data: allPlatformApps } = useQuery({
|
||||
queryKey: ['admin-apps-all'],
|
||||
queryFn: async () => {
|
||||
const result: Record<string, AppDefinition[]> = {}
|
||||
for (const platform of PLATFORMS) {
|
||||
try {
|
||||
result[platform] = await adminAppsApi.getPlatformApps(platform)
|
||||
} catch {
|
||||
result[platform] = []
|
||||
}
|
||||
}
|
||||
return result
|
||||
},
|
||||
})
|
||||
|
||||
const { data: branding } = useQuery({
|
||||
queryKey: ['admin-branding'],
|
||||
queryFn: adminAppsApi.getBranding,
|
||||
})
|
||||
|
||||
// Export handler
|
||||
const handleExport = () => {
|
||||
if (!allPlatformApps) return
|
||||
const remnawaveConfig = exportToRemnawaveFormat(allPlatformApps, branding)
|
||||
const json = JSON.stringify(remnawaveConfig, null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `apps-config-${new Date().toISOString().split('T')[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// Import file handler
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setImportError(null)
|
||||
setImportPreview(null)
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const json = JSON.parse(event.target?.result as string) as RemnawaveConfig
|
||||
const imported = importFromRemnawaveFormat(json)
|
||||
setImportPreview(imported)
|
||||
setShowImportModal(true)
|
||||
} catch (err) {
|
||||
setImportError(t('admin.apps.importError', 'Invalid JSON file'))
|
||||
setShowImportModal(true)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
// Reset file input
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
// Confirm import handler
|
||||
const handleConfirmImport = async () => {
|
||||
if (!importPreview) return
|
||||
try {
|
||||
for (const [platform, apps] of Object.entries(importPreview.platformApps)) {
|
||||
for (const app of apps) {
|
||||
await adminAppsApi.createApp(platform, app)
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-apps'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-apps-all'] })
|
||||
setShowImportModal(false)
|
||||
setImportPreview(null)
|
||||
} catch (err) {
|
||||
setImportError(t('admin.apps.importCreateError', 'Failed to create some apps'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleMoveUp = (index: number) => {
|
||||
if (!apps || index === 0) return
|
||||
const newOrder = [...apps]
|
||||
@@ -468,11 +638,60 @@ export default function AdminApps() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<AppsIcon />
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('admin.apps.title')}</h1>
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<AppsIcon />
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('admin.apps.title')}</h1>
|
||||
</div>
|
||||
|
||||
{/* RemnaWave Toggle & Settings */}
|
||||
<div className="flex items-center gap-2">
|
||||
{remnaWaveStatus?.enabled && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setUseRemnaWave(!useRemnaWave)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
useRemnaWave
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||
}`}
|
||||
title={t('admin.apps.remnaWaveToggle', 'Toggle RemnaWave source')}
|
||||
>
|
||||
<CloudSyncIcon />
|
||||
<span>RemnaWave</span>
|
||||
</button>
|
||||
{useRemnaWave && (
|
||||
<button
|
||||
onClick={() => refetchRemnaWave()}
|
||||
className="p-2 text-dark-400 hover:text-dark-200 hover:bg-dark-700 rounded-lg transition-colors"
|
||||
title={t('admin.apps.refreshRemnaWave', 'Refresh from RemnaWave')}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setRemnaWaveUuidInput(remnaWaveStatus?.config_uuid || '')
|
||||
setShowRemnaWaveSettings(true)
|
||||
}}
|
||||
className="p-2 text-dark-400 hover:text-dark-200 hover:bg-dark-700 rounded-lg transition-colors"
|
||||
title={t('admin.apps.remnaWaveSettings', 'RemnaWave Settings')}
|
||||
>
|
||||
<SettingsIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RemnaWave Info Banner */}
|
||||
{useRemnaWave && (
|
||||
<div className="bg-accent-500/10 border border-accent-500/30 text-accent-200 p-3 rounded-lg text-sm flex items-center gap-2">
|
||||
<CloudSyncIcon />
|
||||
<span>{t('admin.apps.remnaWaveMode', 'Showing apps from RemnaWave panel. Changes here are read-only.')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Platform Tabs */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PLATFORMS.map((platform) => (
|
||||
@@ -490,16 +709,44 @@ export default function AdminApps() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add App Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => setEditingApp({ app: createEmptyApp(selectedPlatform), isNew: true })}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<PlusIcon />
|
||||
{t('admin.apps.addApp')}
|
||||
</button>
|
||||
</div>
|
||||
{/* Action Buttons - Only show when not in RemnaWave mode */}
|
||||
{!useRemnaWave && (
|
||||
<div className="flex flex-wrap gap-3 justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
title={t('admin.apps.importApps', 'Import Apps')}
|
||||
>
|
||||
<UploadIcon />
|
||||
{t('admin.apps.import', 'Import')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={!allPlatformApps}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
title={t('admin.apps.exportApps', 'Export Apps')}
|
||||
>
|
||||
<DownloadIcon />
|
||||
{t('admin.apps.export', 'Export')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEditingApp({ app: createEmptyApp(selectedPlatform), isNew: true })}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<PlusIcon />
|
||||
{t('admin.apps.addApp')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Apps List */}
|
||||
{isLoading ? (
|
||||
@@ -513,23 +760,25 @@ export default function AdminApps() {
|
||||
key={app.id}
|
||||
className="card flex items-center gap-4 p-4"
|
||||
>
|
||||
{/* Reorder buttons */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => handleMoveUp(index)}
|
||||
disabled={index === 0}
|
||||
className="p-1 text-dark-400 hover:text-dark-200 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveDown(index)}
|
||||
disabled={index === apps.length - 1}
|
||||
className="p-1 text-dark-400 hover:text-dark-200 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</button>
|
||||
</div>
|
||||
{/* Reorder buttons - Only show when not in RemnaWave mode */}
|
||||
{!useRemnaWave && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={() => handleMoveUp(index)}
|
||||
disabled={index === 0}
|
||||
className="p-1 text-dark-400 hover:text-dark-200 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveDown(index)}
|
||||
disabled={index === apps.length - 1}
|
||||
className="p-1 text-dark-400 hover:text-dark-200 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* App Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -544,30 +793,32 @@ export default function AdminApps() {
|
||||
<div className="text-xs text-dark-400 mt-1">{app.urlScheme}</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCopyTarget({ appId: app.id, platform: selectedPlatform })}
|
||||
className="p-2 text-dark-400 hover:text-dark-200 hover:bg-dark-700 rounded"
|
||||
title={t('admin.apps.copyTo')}
|
||||
>
|
||||
<CopyIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingApp({ app, isNew: false })}
|
||||
className="p-2 text-dark-400 hover:text-dark-200 hover:bg-dark-700 rounded"
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(app.id)}
|
||||
className="p-2 text-error-400 hover:text-error-300 hover:bg-error-500/10 rounded"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
{/* Actions - Only show when not in RemnaWave mode */}
|
||||
{!useRemnaWave && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCopyTarget({ appId: app.id, platform: selectedPlatform })}
|
||||
className="p-2 text-dark-400 hover:text-dark-200 hover:bg-dark-700 rounded"
|
||||
title={t('admin.apps.copyTo')}
|
||||
>
|
||||
<CopyIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingApp({ app, isNew: false })}
|
||||
className="p-2 text-dark-400 hover:text-dark-200 hover:bg-dark-700 rounded"
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(app.id)}
|
||||
className="p-2 text-error-400 hover:text-error-300 hover:bg-error-500/10 rounded"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -624,6 +875,149 @@ export default function AdminApps() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Modal */}
|
||||
{showImportModal && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-900 rounded-xl p-6 w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<h3 className="text-lg font-semibold text-dark-100 mb-4">
|
||||
{t('admin.apps.importPreview', 'Import Preview')}
|
||||
</h3>
|
||||
|
||||
{importError && (
|
||||
<div className="bg-error-500/10 border border-error-500/30 text-error-400 p-3 rounded-lg mb-4">
|
||||
{importError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importPreview && (
|
||||
<div className="flex-1 overflow-y-auto space-y-4 mb-4">
|
||||
{Object.entries(importPreview.platformApps).map(([platform, platformApps]) => (
|
||||
platformApps.length > 0 && (
|
||||
<div key={platform} className="bg-dark-800/50 rounded-lg p-4">
|
||||
<h4 className="font-medium text-dark-200 mb-2">
|
||||
{PLATFORM_LABELS[platform] || platform} ({platformApps.length})
|
||||
</h4>
|
||||
<ul className="space-y-1">
|
||||
{platformApps.map((app, idx) => (
|
||||
<li key={idx} className="text-sm text-dark-400 flex items-center gap-2">
|
||||
{app.isFeatured && <StarIcon filled />}
|
||||
<span>{app.name}</span>
|
||||
<span className="text-dark-600 font-mono text-xs">({app.urlScheme || '-'})</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
{Object.values(importPreview.platformApps).every(arr => arr.length === 0) && (
|
||||
<div className="text-dark-400 text-center py-8">
|
||||
{t('admin.apps.noAppsToImport', 'No apps to import')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 justify-end pt-4 border-t border-dark-700">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowImportModal(false)
|
||||
setImportPreview(null)
|
||||
setImportError(null)
|
||||
}}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
{importPreview && !importError && (
|
||||
<button
|
||||
onClick={handleConfirmImport}
|
||||
className="btn-primary"
|
||||
>
|
||||
{t('admin.apps.confirmImport', 'Import Apps')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RemnaWave Settings Modal */}
|
||||
{showRemnaWaveSettings && (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-900 rounded-xl p-6 w-full max-w-lg">
|
||||
<h3 className="text-lg font-semibold text-dark-100 mb-4 flex items-center gap-2">
|
||||
<CloudSyncIcon />
|
||||
{t('admin.apps.remnaWaveSettings', 'RemnaWave Settings')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-2">
|
||||
{t('admin.apps.remnaWaveConfigUuid', 'Config UUID')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={remnaWaveUuidInput}
|
||||
onChange={(e) => setRemnaWaveUuidInput(e.target.value)}
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
className="input w-full font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
{t('admin.apps.remnaWaveConfigUuidHint', 'UUID of subscription page config from RemnaWave panel')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Available configs from RemnaWave */}
|
||||
{isLoadingConfigs ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : availableConfigs && availableConfigs.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-2">
|
||||
{t('admin.apps.availableConfigs', 'Available configs')}
|
||||
</label>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{availableConfigs.map((config) => (
|
||||
<button
|
||||
key={config.uuid}
|
||||
onClick={() => setRemnaWaveUuidInput(config.uuid)}
|
||||
className={`w-full text-left p-3 rounded-lg border transition-colors ${
|
||||
remnaWaveUuidInput === config.uuid
|
||||
? 'border-accent-500 bg-accent-500/10'
|
||||
: 'border-dark-700 bg-dark-800/50 hover:border-dark-600'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-dark-100">{config.name}</div>
|
||||
<div className="text-xs text-dark-500 font-mono mt-1">{config.uuid}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end mt-6 pt-4 border-t border-dark-700">
|
||||
<button
|
||||
onClick={() => setShowRemnaWaveSettings(false)}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setUuidMutation.mutate(remnaWaveUuidInput || null)}
|
||||
disabled={setUuidMutation.isPending}
|
||||
className="btn-primary"
|
||||
>
|
||||
{setUuidMutation.isPending
|
||||
? t('common.loading')
|
||||
: t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -184,32 +184,32 @@ function RevenueChart({ data }: { data: { date: string; amount_rubles: number }[
|
||||
)
|
||||
}
|
||||
|
||||
const maxValue = Math.max(...data.map(d => d.amount_rubles), 1)
|
||||
const last7Days = data.slice(-7)
|
||||
const maxValue = Math.max(...last7Days.map(d => d.amount_rubles), 1)
|
||||
|
||||
return (
|
||||
<div className="h-48">
|
||||
<div className="flex items-end justify-between h-36 gap-2">
|
||||
{last7Days.map((item, index) => {
|
||||
const height = (item.amount_rubles / maxValue) * 100
|
||||
const date = new Date(item.date)
|
||||
const dayName = date.toLocaleDateString('ru-RU', { weekday: 'short' })
|
||||
<div className="space-y-3">
|
||||
{last7Days.map((item, index) => {
|
||||
const percentage = (item.amount_rubles / maxValue) * 100
|
||||
const date = new Date(item.date)
|
||||
const dayName = date.toLocaleDateString('ru-RU', { weekday: 'short' })
|
||||
const dayNum = date.getDate()
|
||||
|
||||
return (
|
||||
<div key={index} className="flex-1 flex flex-col items-center gap-2">
|
||||
<div
|
||||
className="w-full bg-accent-500/80 rounded-t-lg hover:bg-accent-500 transition-colors cursor-pointer group relative"
|
||||
style={{ height: `${Math.max(height, 4)}%` }}
|
||||
>
|
||||
<div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-dark-800 px-2 py-1 rounded text-xs text-dark-100 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-10 border border-dark-600">
|
||||
{formatAmount(item.amount_rubles)} {currencySymbol}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{dayName}</div>
|
||||
return (
|
||||
<div key={index} className="group">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-dark-300 font-medium capitalize">{dayName}, {dayNum}</span>
|
||||
<span className="text-sm font-semibold text-dark-100">{formatAmount(item.amount_rubles)} {currencySymbol}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="h-3 bg-dark-700/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-accent-600 to-accent-400 rounded-full transition-all duration-500 ease-out group-hover:from-accent-500 group-hover:to-accent-300"
|
||||
style={{ width: `${Math.max(percentage, 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -497,6 +497,58 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tariff Stats */}
|
||||
{stats?.tariff_stats && stats.tariff_stats.tariffs.length > 0 && (
|
||||
<div className="bg-dark-800/30 backdrop-blur rounded-xl border border-dark-700 p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<SubscriptionIcon />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('adminDashboard.tariffs.title')}</h2>
|
||||
<p className="text-sm text-dark-400">{t('adminDashboard.tariffs.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-dark-700">
|
||||
<th className="text-left text-xs text-dark-500 font-medium py-3 px-2">{t('adminDashboard.tariffs.tariffName')}</th>
|
||||
<th className="text-center text-xs text-dark-500 font-medium py-3 px-2">{t('adminDashboard.tariffs.activeSubscriptions')}</th>
|
||||
<th className="text-center text-xs text-dark-500 font-medium py-3 px-2">{t('adminDashboard.tariffs.trialSubscriptions')}</th>
|
||||
<th className="text-center text-xs text-dark-500 font-medium py-3 px-2">{t('adminDashboard.tariffs.purchasedToday')}</th>
|
||||
<th className="text-center text-xs text-dark-500 font-medium py-3 px-2">{t('adminDashboard.tariffs.purchasedWeek')}</th>
|
||||
<th className="text-center text-xs text-dark-500 font-medium py-3 px-2">{t('adminDashboard.tariffs.purchasedMonth')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.tariff_stats.tariffs.map((tariff) => (
|
||||
<tr key={tariff.tariff_id} className="border-b border-dark-700/50 hover:bg-dark-800/50 transition-colors">
|
||||
<td className="py-3 px-2">
|
||||
<span className="font-medium text-dark-100">{tariff.tariff_name}</span>
|
||||
</td>
|
||||
<td className="text-center py-3 px-2">
|
||||
<span className="text-success-400 font-semibold">{tariff.active_subscriptions}</span>
|
||||
</td>
|
||||
<td className="text-center py-3 px-2">
|
||||
<span className="text-warning-400 font-semibold">{tariff.trial_subscriptions}</span>
|
||||
</td>
|
||||
<td className="text-center py-3 px-2">
|
||||
<span className="text-dark-200">{tariff.purchased_today}</span>
|
||||
</td>
|
||||
<td className="text-center py-3 px-2">
|
||||
<span className="text-dark-200">{tariff.purchased_week}</span>
|
||||
</td>
|
||||
<td className="text-center py-3 px-2">
|
||||
<span className="text-dark-200">{tariff.purchased_month}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { adminApi, AdminTicket, AdminTicketDetail, AdminTicketMessage } from '../api/admin'
|
||||
@@ -86,6 +86,7 @@ export default function AdminTickets() {
|
||||
const [statusFilter, setStatusFilter] = useState<string>('')
|
||||
const [replyText, setReplyText] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ['admin-ticket-stats'],
|
||||
@@ -162,7 +163,19 @@ export default function AdminTickets() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('admin.tickets.title')}</h1>
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('admin.tickets.title')}</h1>
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<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.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
{t('admin.tickets.settings')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
@@ -380,6 +393,198 @@ export default function AdminTickets() {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Modal */}
|
||||
{showSettings && <TicketSettingsModal onClose={() => setShowSettings(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TicketSettingsModal({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: ['ticket-settings'],
|
||||
queryFn: adminApi.getTicketSettings,
|
||||
})
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
sla_enabled: settings?.sla_enabled ?? true,
|
||||
sla_minutes: settings?.sla_minutes ?? 5,
|
||||
sla_check_interval_seconds: settings?.sla_check_interval_seconds ?? 60,
|
||||
sla_reminder_cooldown_minutes: settings?.sla_reminder_cooldown_minutes ?? 15,
|
||||
support_system_mode: settings?.support_system_mode ?? 'both',
|
||||
})
|
||||
|
||||
// Update form when settings load
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
setFormData({
|
||||
sla_enabled: settings.sla_enabled,
|
||||
sla_minutes: settings.sla_minutes,
|
||||
sla_check_interval_seconds: settings.sla_check_interval_seconds,
|
||||
sla_reminder_cooldown_minutes: settings.sla_reminder_cooldown_minutes,
|
||||
support_system_mode: settings.support_system_mode,
|
||||
})
|
||||
}
|
||||
}, [settings])
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: adminApi.updateTicketSettings,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['ticket-settings'] })
|
||||
onClose()
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
updateMutation.mutate(formData)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50" onClick={onClose}>
|
||||
<div className="card max-w-2xl w-full max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-bold text-dark-50">{t('admin.tickets.settings')}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-dark-400 hover:text-dark-200 transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Support System Mode */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-100 mb-2">
|
||||
{t('admin.tickets.supportMode')}
|
||||
</label>
|
||||
<select
|
||||
value={formData.support_system_mode}
|
||||
onChange={(e) => setFormData({ ...formData, support_system_mode: e.target.value })}
|
||||
className="input"
|
||||
>
|
||||
<option value="both">{t('admin.tickets.modeBoth')}</option>
|
||||
<option value="tickets">{t('admin.tickets.modeTickets')}</option>
|
||||
<option value="contact">{t('admin.tickets.modeContact')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-dark-500 mt-1">{t('admin.tickets.supportModeDesc')}</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-dark-800/50 pt-6">
|
||||
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.tickets.slaSettings')}</h3>
|
||||
|
||||
{/* SLA Enabled */}
|
||||
<div>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.sla_enabled}
|
||||
onChange={(e) => setFormData({ ...formData, sla_enabled: e.target.checked })}
|
||||
className="w-5 h-5 rounded border-dark-700 bg-dark-800 text-accent-500 focus:ring-2 focus:ring-accent-500 focus:ring-offset-0"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-dark-100 font-medium">{t('admin.tickets.slaEnabled')}</div>
|
||||
<div className="text-sm text-dark-500">{t('admin.tickets.slaEnabledDesc')}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SLA Minutes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-100 mb-2">
|
||||
{t('admin.tickets.slaMinutes')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="1440"
|
||||
value={formData.sla_minutes}
|
||||
onChange={(e) => setFormData({ ...formData, sla_minutes: parseInt(e.target.value) })}
|
||||
className="input"
|
||||
disabled={!formData.sla_enabled}
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">{t('admin.tickets.slaMinutesDesc')}</p>
|
||||
</div>
|
||||
|
||||
{/* Check Interval */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-100 mb-2">
|
||||
{t('admin.tickets.checkInterval')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="30"
|
||||
max="600"
|
||||
value={formData.sla_check_interval_seconds}
|
||||
onChange={(e) => setFormData({ ...formData, sla_check_interval_seconds: parseInt(e.target.value) })}
|
||||
className="input"
|
||||
disabled={!formData.sla_enabled}
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">{t('admin.tickets.checkIntervalDesc')}</p>
|
||||
</div>
|
||||
|
||||
{/* Reminder Cooldown */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-100 mb-2">
|
||||
{t('admin.tickets.reminderCooldown')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="120"
|
||||
value={formData.sla_reminder_cooldown_minutes}
|
||||
onChange={(e) => setFormData({ ...formData, sla_reminder_cooldown_minutes: parseInt(e.target.value) })}
|
||||
className="input"
|
||||
disabled={!formData.sla_enabled}
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">{t('admin.tickets.reminderCooldownDesc')}</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-3 justify-end pt-4 border-t border-dark-800/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending}
|
||||
className="btn-primary"
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
{t('common.saving')}
|
||||
</span>
|
||||
) : (
|
||||
t('common.save')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{updateMutation.isError && (
|
||||
<div className="p-3 rounded-lg bg-error-500/10 border border-error-500/30 text-error-400 text-sm">
|
||||
{t('admin.tickets.settingsUpdateError')}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -198,7 +198,6 @@ export default function Balance() {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-dark-500">{t('balance.useBot')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -212,7 +211,14 @@ export default function Balance() {
|
||||
</div>
|
||||
) : transactions?.items && transactions.items.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{transactions.items.map((tx) => (
|
||||
{transactions.items.map((tx) => {
|
||||
// API returns negative values for debits, positive for credits
|
||||
const isPositive = tx.amount_rubles >= 0
|
||||
const displayAmount = Math.abs(tx.amount_rubles)
|
||||
const sign = isPositive ? '+' : '-'
|
||||
const colorClass = isPositive ? 'text-success-400' : 'text-error-400'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="flex items-center justify-between p-4 rounded-xl bg-dark-800/30 border border-dark-700/30"
|
||||
@@ -230,11 +236,12 @@ export default function Balance() {
|
||||
<div className="text-sm text-dark-400">{tx.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`text-lg font-semibold ${tx.amount_kopeks > 0 ? 'text-success-400' : 'text-error-400'}`}>
|
||||
{tx.amount_kopeks > 0 ? '+' : ''}{formatAmount(tx.amount_rubles)} {currencySymbol}
|
||||
<div className={`text-lg font-semibold ${colorClass}`}>
|
||||
{sign}{formatAmount(displayAmount)} {currencySymbol}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
|
||||
@@ -198,9 +198,9 @@ export default function Referral() {
|
||||
</div>
|
||||
</div>
|
||||
{ref.has_paid ? (
|
||||
<span className="badge-success">Paid</span>
|
||||
<span className="badge-success">{t('referral.status.paid')}</span>
|
||||
) : (
|
||||
<span className="badge-neutral">Pending</span>
|
||||
<span className="badge-neutral">{t('referral.status.pending')}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -232,7 +232,7 @@ export default function Referral() {
|
||||
{earning.referral_first_name || earning.referral_username || 'Referral'}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500 mt-0.5">
|
||||
{earning.reason} • {new Date(earning.created_at).toLocaleDateString()}
|
||||
{t(`referral.reasons.${earning.reason}`, earning.reason)} • {new Date(earning.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-success-400 font-semibold">
|
||||
|
||||
@@ -220,7 +220,9 @@ export default function Subscription() {
|
||||
if (!selectedTariff) {
|
||||
throw new Error('Tariff not selected')
|
||||
}
|
||||
const days = useCustomDays ? customDays : (selectedTariffPeriod?.days || 30)
|
||||
// For daily tariffs, always use 1 day
|
||||
const isDailyTariff = selectedTariff.is_daily || (selectedTariff.daily_price_kopeks && selectedTariff.daily_price_kopeks > 0)
|
||||
const days = isDailyTariff ? 1 : (useCustomDays ? customDays : (selectedTariffPeriod?.days || 30))
|
||||
const trafficGb = useCustomTraffic && selectedTariff.custom_traffic_enabled ? customTrafficGb : undefined
|
||||
return subscriptionApi.purchaseTariff(selectedTariff.id, days, trafficGb)
|
||||
},
|
||||
@@ -484,7 +486,7 @@ export default function Subscription() {
|
||||
{/* Purchased Traffic Packages */}
|
||||
{subscription.traffic_purchases && subscription.traffic_purchases.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="text-sm text-dark-500 mb-3">{t('subscription.purchasedTraffic', 'Докупленный трафик')}</div>
|
||||
<div className="text-sm text-dark-500 mb-3">{t('subscription.purchasedTraffic')}</div>
|
||||
<div className="space-y-3">
|
||||
{subscription.traffic_purchases.map((purchase) => (
|
||||
<div key={purchase.id} className="p-3 rounded-lg bg-dark-800/50 border border-dark-700/50">
|
||||
@@ -495,14 +497,26 @@ export default function Subscription() {
|
||||
</svg>
|
||||
<span className="text-base font-semibold text-dark-100">{purchase.traffic_gb} ГБ</span>
|
||||
</div>
|
||||
<div className="text-sm text-dark-400">
|
||||
{purchase.days_remaining === 0 ? (
|
||||
<span className="text-orange-500">Истекает сегодня</span>
|
||||
) : purchase.days_remaining === 1 ? (
|
||||
<span className="text-orange-400">Остался 1 день</span>
|
||||
) : (
|
||||
<span>Осталось {purchase.days_remaining} {purchase.days_remaining >= 5 ? 'дней' : purchase.days_remaining === 1 ? 'день' : 'дня'}</span>
|
||||
)}
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="text-sm text-dark-400">
|
||||
{purchase.days_remaining === 0 ? (
|
||||
<span className="text-orange-500">Истекает сегодня</span>
|
||||
) : purchase.days_remaining === 1 ? (
|
||||
<span className="text-orange-400">Остался 1 день</span>
|
||||
) : (
|
||||
<span>Осталось {purchase.days_remaining} {purchase.days_remaining >= 5 ? 'дней' : purchase.days_remaining === 1 ? 'день' : 'дня'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500 flex items-center gap-1">
|
||||
<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>
|
||||
<span>{t('subscription.trafficResetAt', 'Сброс')}: {new Date(purchase.expires_at).toLocaleDateString(undefined, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative h-1.5 bg-dark-700 rounded-full overflow-hidden">
|
||||
@@ -932,6 +946,20 @@ export default function Subscription() {
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Legacy subscription notice - if user has subscription without tariff */}
|
||||
{subscription && !subscription.is_trial && !subscription.tariff_id && (
|
||||
<div className="mb-6 p-4 bg-accent-500/10 border border-accent-500/30 rounded-xl">
|
||||
<div className="text-accent-400 font-medium mb-2">📦 Выберите тариф для продления</div>
|
||||
<div className="text-sm text-dark-300">
|
||||
Ваша текущая подписка была создана до введения тарифов.
|
||||
Для продления необходимо выбрать один из доступных тарифов.
|
||||
</div>
|
||||
<div className="text-xs text-dark-500 mt-2">
|
||||
⚠️ Ваша текущая подписка продолжит действовать до окончания срока.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Switch Tariff Preview Modal */}
|
||||
{switchTariffId && (
|
||||
<div ref={switchModalRef} className="mb-6 bg-dark-800/50 rounded-xl p-5 space-y-4">
|
||||
@@ -1018,7 +1046,24 @@ export default function Subscription() {
|
||||
)}
|
||||
|
||||
{!showTariffPurchase ? (
|
||||
/* Tariff List - current tariff first */
|
||||
<>
|
||||
{/* Promo group discount banner */}
|
||||
{tariffs.some(t => t.promo_group_name) && (
|
||||
<div className="mb-4 p-3 bg-success-500/10 border border-success-500/30 rounded-xl flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-success-500/20 rounded-lg flex items-center justify-center text-success-400">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-success-400">
|
||||
Ваша группа: {tariffs.find(t => t.promo_group_name)?.promo_group_name}
|
||||
</div>
|
||||
<div className="text-xs text-dark-400">Персональные скидки применены к ценам</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Tariff List - current tariff first */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{[...tariffs]
|
||||
.sort((a, b) => {
|
||||
@@ -1031,6 +1076,8 @@ export default function Subscription() {
|
||||
.map((tariff) => {
|
||||
const isCurrentTariff = tariff.is_current || tariff.id === subscription?.tariff_id
|
||||
const canSwitch = subscription && subscription.tariff_id && !isCurrentTariff && !subscription.is_trial
|
||||
// Если есть подписка БЕЗ tariff_id (классическая) - разрешить выбрать тариф
|
||||
const isLegacySubscription = subscription && !subscription.is_trial && !subscription.tariff_id
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1068,18 +1115,37 @@ export default function Subscription() {
|
||||
{(() => {
|
||||
// Daily tariff price (is_daily + daily_price_kopeks)
|
||||
const dailyPrice = tariff.daily_price_kopeks ?? tariff.price_per_day_kopeks ?? 0
|
||||
const originalDailyPrice = tariff.original_daily_price_kopeks || 0
|
||||
if (dailyPrice > 0) {
|
||||
return (
|
||||
<span>
|
||||
<span className="text-accent-400 font-medium">{formatPrice(dailyPrice)}</span> / день
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-accent-400 font-medium">{formatPrice(dailyPrice)}</span>
|
||||
{originalDailyPrice > dailyPrice && (
|
||||
<span className="text-dark-500 text-xs line-through">{formatPrice(originalDailyPrice)}</span>
|
||||
)}
|
||||
<span>/ день</span>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
// Period-based price
|
||||
if (tariff.periods.length > 0) {
|
||||
const firstPeriod = tariff.periods[0]
|
||||
const hasDiscount = firstPeriod?.original_price_kopeks && firstPeriod.original_price_kopeks > firstPeriod.price_kopeks
|
||||
return (
|
||||
<span>
|
||||
{t('subscription.from')} <span className="text-accent-400 font-medium">{formatPrice(tariff.periods[0]?.price_kopeks || 0)}</span>
|
||||
<span className="flex items-center gap-2 flex-wrap">
|
||||
<span>{t('subscription.from')}</span>
|
||||
<span className="text-accent-400 font-medium">{formatPrice(firstPeriod?.price_kopeks || 0)}</span>
|
||||
{hasDiscount && (
|
||||
<>
|
||||
<span className="text-dark-500 text-xs line-through">{formatPrice(firstPeriod.original_price_kopeks!)}</span>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1108,34 +1174,45 @@ export default function Subscription() {
|
||||
{t('subscription.extend')}
|
||||
</button>
|
||||
)
|
||||
) : isLegacySubscription ? (
|
||||
/* Legacy subscription without tariff - allow selecting tariff for renewal */
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedTariff(tariff)
|
||||
setSelectedTariffPeriod(tariff.periods[0] || null)
|
||||
setShowTariffPurchase(true)
|
||||
}}
|
||||
className="btn-primary flex-1 py-2 text-sm"
|
||||
>
|
||||
Выбрать для продления
|
||||
</button>
|
||||
) : canSwitch ? (
|
||||
/* Other tariffs with existing tariff - switch button */
|
||||
<button
|
||||
onClick={() => setSwitchTariffId(tariff.id)}
|
||||
className="btn-secondary flex-1 py-2 text-sm"
|
||||
>
|
||||
{t('subscription.switchTariff.switch')}
|
||||
</button>
|
||||
) : (
|
||||
/* Other tariffs - only switch button (no extend) */
|
||||
canSwitch ? (
|
||||
<button
|
||||
onClick={() => setSwitchTariffId(tariff.id)}
|
||||
className="btn-secondary flex-1 py-2 text-sm"
|
||||
>
|
||||
{t('subscription.switchTariff.switch')}
|
||||
</button>
|
||||
) : (
|
||||
/* No subscription or trial - purchase button */
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedTariff(tariff)
|
||||
setSelectedTariffPeriod(tariff.periods[0] || null)
|
||||
setShowTariffPurchase(true)
|
||||
}}
|
||||
className="btn-primary flex-1 py-2 text-sm"
|
||||
>
|
||||
{t('subscription.purchase')}
|
||||
</button>
|
||||
)
|
||||
/* No subscription or trial - purchase button */
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedTariff(tariff)
|
||||
setSelectedTariffPeriod(tariff.periods[0] || null)
|
||||
setShowTariffPurchase(true)
|
||||
}}
|
||||
className="btn-primary flex-1 py-2 text-sm"
|
||||
>
|
||||
{t('subscription.purchase')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : selectedTariff && (
|
||||
/* Tariff Purchase Form */
|
||||
<div ref={tariffPurchaseRef} className="space-y-6">
|
||||
@@ -1260,14 +1337,24 @@ export default function Subscription() {
|
||||
setSelectedTariffPeriod(period)
|
||||
setUseCustomDays(false)
|
||||
}}
|
||||
className={`p-4 rounded-xl border text-left transition-all ${
|
||||
className={`p-4 rounded-xl border text-left transition-all relative ${
|
||||
selectedTariffPeriod?.days === period.days && !useCustomDays
|
||||
? 'border-accent-500 bg-accent-500/10'
|
||||
: 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30'
|
||||
}`}
|
||||
>
|
||||
{period.discount_percent && period.discount_percent > 0 && (
|
||||
<div className="absolute -top-2 -right-2 px-2 py-0.5 bg-success-500 text-white text-xs font-medium rounded-full">
|
||||
-{period.discount_percent}%
|
||||
</div>
|
||||
)}
|
||||
<div className="text-lg font-semibold text-dark-100">{period.label}</div>
|
||||
<div className="text-accent-400 font-medium">{formatPrice(period.price_kopeks)}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-accent-400 font-medium">{formatPrice(period.price_kopeks)}</span>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500 mt-1">{formatPrice(period.price_per_month_kopeks)}/{t('subscription.month')}</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -120,6 +120,8 @@ function MessageMedia({ message, t }: { message: TicketMessage; t: (key: string)
|
||||
}
|
||||
|
||||
export default function Support() {
|
||||
console.log('[Support] Component loaded - VERSION 2024-01-16-v3')
|
||||
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedTicket, setSelectedTicket] = useState<TicketDetail | null>(null)
|
||||
@@ -269,17 +271,63 @@ export default function Support() {
|
||||
|
||||
// If tickets are disabled, show redirect message
|
||||
if (supportConfig && !supportConfig.tickets_enabled) {
|
||||
console.log('[Support] Tickets disabled, config:', supportConfig)
|
||||
|
||||
const getSupportMessage = () => {
|
||||
console.log('[Support] Getting support message for type:', supportConfig.support_type)
|
||||
|
||||
if (supportConfig.support_type === 'profile') {
|
||||
const supportUsername = supportConfig.support_username || '@support'
|
||||
console.log('[Support] Opening profile:', supportUsername)
|
||||
return {
|
||||
title: t('support.ticketsDisabled'),
|
||||
message: t('support.useProfile'),
|
||||
buttonText: t('support.goToProfile'),
|
||||
message: t('support.contactSupport', { username: supportUsername }),
|
||||
buttonText: t('support.contactUs'),
|
||||
buttonAction: () => {
|
||||
console.log('[Support] Button clicked, opening:', supportUsername)
|
||||
const webApp = window.Telegram?.WebApp
|
||||
if (webApp?.close) {
|
||||
webApp.close()
|
||||
|
||||
// Extract username without @
|
||||
const username = supportUsername.startsWith('@')
|
||||
? supportUsername.slice(1)
|
||||
: supportUsername
|
||||
|
||||
// Try different URL formats
|
||||
const telegramUrl = `tg://resolve?domain=${username}`
|
||||
const webUrl = `https://t.me/${username}`
|
||||
|
||||
console.log('[Support] Telegram URL:', telegramUrl)
|
||||
console.log('[Support] Web URL:', webUrl)
|
||||
console.log('[Support] WebApp methods:', {
|
||||
openTelegramLink: !!webApp?.openTelegramLink,
|
||||
openLink: !!webApp?.openLink,
|
||||
})
|
||||
|
||||
// Try openTelegramLink first (for tg:// links)
|
||||
if (webApp?.openTelegramLink) {
|
||||
console.log('[Support] Using openTelegramLink with web URL')
|
||||
try {
|
||||
webApp.openTelegramLink(webUrl)
|
||||
return
|
||||
} catch (e) {
|
||||
console.error('[Support] openTelegramLink failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to openLink
|
||||
if (webApp?.openLink) {
|
||||
console.log('[Support] Using openLink')
|
||||
try {
|
||||
webApp.openLink(webUrl)
|
||||
return
|
||||
} catch (e) {
|
||||
console.error('[Support] openLink failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort - window.open
|
||||
console.log('[Support] Using window.open')
|
||||
window.open(webUrl, '_blank')
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -300,20 +348,34 @@ export default function Support() {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: contact support (should not normally happen if config is correct)
|
||||
const supportUsername = supportConfig.support_username || '@support'
|
||||
console.log('[Support] Fallback: Opening profile:', supportUsername)
|
||||
return {
|
||||
title: t('support.ticketsDisabled'),
|
||||
message: t('support.contactSupport', { username: supportUsername }),
|
||||
buttonText: t('support.contactUs'),
|
||||
buttonAction: () => {
|
||||
console.log('[Support] Fallback button clicked, opening:', supportUsername)
|
||||
const webApp = window.Telegram?.WebApp
|
||||
const url = supportUsername.startsWith('@')
|
||||
? `https://t.me/${supportUsername.slice(1)}`
|
||||
: `https://t.me/${supportUsername}`
|
||||
|
||||
// Extract username without @
|
||||
const username = supportUsername.startsWith('@')
|
||||
? supportUsername.slice(1)
|
||||
: supportUsername
|
||||
|
||||
const webUrl = `https://t.me/${username}`
|
||||
console.log('[Support] Fallback opening URL:', webUrl)
|
||||
|
||||
if (webApp?.openTelegramLink) {
|
||||
webApp.openTelegramLink(url)
|
||||
console.log('[Support] Fallback using openTelegramLink')
|
||||
webApp.openTelegramLink(webUrl)
|
||||
} else if (webApp?.openLink) {
|
||||
console.log('[Support] Fallback using openLink')
|
||||
webApp.openLink(webUrl)
|
||||
} else {
|
||||
window.open(url, '_blank')
|
||||
console.log('[Support] Fallback using window.open')
|
||||
window.open(webUrl, '_blank')
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -214,6 +214,14 @@ export interface TariffPeriod {
|
||||
price_label: string
|
||||
price_per_month_kopeks: number
|
||||
price_per_month_label: string
|
||||
// Discount info (if promo group discount applied)
|
||||
original_price_kopeks?: number
|
||||
original_price_label?: string
|
||||
original_per_month_kopeks?: number
|
||||
original_per_month_label?: string
|
||||
discount_percent?: number
|
||||
discount_amount_kopeks?: number
|
||||
discount_label?: string
|
||||
}
|
||||
|
||||
export interface TariffServer {
|
||||
@@ -254,6 +262,14 @@ export interface Tariff {
|
||||
// Daily tariff options
|
||||
is_daily?: boolean
|
||||
daily_price_kopeks?: number
|
||||
// Promo group discount info
|
||||
promo_group_name?: string
|
||||
original_device_price_kopeks?: number
|
||||
device_discount_percent?: number
|
||||
original_daily_price_kopeks?: number
|
||||
daily_discount_percent?: number
|
||||
original_price_per_day_kopeks?: number
|
||||
custom_days_discount_percent?: number
|
||||
}
|
||||
|
||||
export interface TariffsPurchaseOptions {
|
||||
@@ -348,6 +364,12 @@ export interface Transaction {
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
export interface PaymentMethodOption {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export interface PaymentMethod {
|
||||
id: string
|
||||
name: string
|
||||
@@ -355,6 +377,7 @@ export interface PaymentMethod {
|
||||
min_amount_kopeks: number
|
||||
max_amount_kopeks: number
|
||||
is_available: boolean
|
||||
options?: PaymentMethodOption[] | null
|
||||
}
|
||||
|
||||
// Referral types
|
||||
|
||||
Reference in New Issue
Block a user