Merge pull request #1 from BEDOLAGA-DEV/main

w
This commit is contained in:
Egor
2026-01-16 08:37:36 +03:00
committed by GitHub
26 changed files with 3310 additions and 1296 deletions

View File

@@ -1,7 +1,8 @@
# ===== BUILD-TIME VARIABLES ===== # ===== 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 # Same server (with proxy): /api
# Remote server (direct): https://api.yourbot.com/cabinet # Remote server (direct): https://api.yourbot.com/cabinet
VITE_API_URL=/api VITE_API_URL=/api
@@ -9,7 +10,7 @@ VITE_API_URL=/api
# Telegram bot username for Login Widget (without @) # Telegram bot username for Login Widget (without @)
VITE_TELEGRAM_BOT_USERNAME=your_bot_username VITE_TELEGRAM_BOT_USERNAME=your_bot_username
# ===== BRANDING ===== # ===== BRANDING (optional) =====
# App name displayed in header and browser tab # App name displayed in header and browser tab
VITE_APP_NAME=VPN Cabinet VITE_APP_NAME=VPN Cabinet
@@ -17,21 +18,7 @@ VITE_APP_NAME=VPN Cabinet
VITE_APP_LOGO=V VITE_APP_LOGO=V
# ===== RUNTIME VARIABLES ===== # ===== RUNTIME VARIABLES =====
# These are used by Caddy at runtime
# Backend URL for reverse proxy (only if using proxy mode with VITE_API_URL=/api) # Port to expose the frontend on host machine
# Development: http://host.docker.internal:8080 # Default: 3000 (http://localhost:3000)
# Production (same network): http://remnawave_bot:8080
# Remote server: https://api.yourbot.com
BACKEND_URL=https://api.yourbot.com
# Port to expose the frontend
CABINET_PORT=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
View 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
View File

@@ -0,0 +1,32 @@
---
name: Feat Bedolage
about: Создать запрос на фичу «Бедолаге»
title: "[FEAT] Заголовок"
labels: ''
assignees: ''
---
## Описание / Конфигурация
Кратко описываем что хотим.
Указываем настройки из `.env` конфига (если таковые к месту):
```yml
```
## Текущее поведение
### Скрины поведения
Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию.
## Желаемое поведение
Опишите, как хотелось бы, чтобы было.
## Дополнительная информация
Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию.

View File

@@ -33,6 +33,7 @@ jobs:
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=sha
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push to Docker Hub - name: Build and push to Docker Hub
@@ -47,6 +48,6 @@ jobs:
build-args: | build-args: |
VITE_API_URL=/api VITE_API_URL=/api
VITE_TELEGRAM_BOT_USERNAME= VITE_TELEGRAM_BOT_USERNAME=
VITE_APP_NAME=Bedolaga Cabinet VITE_APP_NAME=Cabinet
VITE_APP_LOGO=V VITE_APP_LOGO=V
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

View File

@@ -45,7 +45,7 @@ jobs:
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=semver,pattern={{major}}
type=sha,prefix={{branch}}- type=sha
type=raw,value=latest,enable={{is_default_branch}} type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image - name: Build and push Docker image
@@ -60,5 +60,5 @@ jobs:
build-args: | build-args: |
VITE_API_URL=/api VITE_API_URL=/api
VITE_TELEGRAM_BOT_USERNAME= VITE_TELEGRAM_BOT_USERNAME=
VITE_APP_NAME=Bedolaga Cabinet VITE_APP_NAME=Cabinet
VITE_APP_LOGO=V VITE_APP_LOGO=V

277
README.md
View File

@@ -1,6 +1,6 @@
# Bedolaga Cabinet - Web Interface # Bedolaga Cabinet - Web Interface
Современный веб-интерфейс личного кабинета для VPN бота на базе [Remnawave Bedolaga Telegram Bot](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot) Современный веб-интерфейс личного кабинета для VPN бота на базе [Remnawave Bedolaga Telegram Bot](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot).
## Возможности ## Возможности
@@ -11,6 +11,8 @@
- 🌐 Мультиязычность (EN/RU) - 🌐 Мультиязычность (EN/RU)
- 📱 Адаптивный дизайн - 📱 Адаптивный дизайн
- 🎨 Настраиваемый брендинг - 🎨 Настраиваемый брендинг
- Админпанель для управлени ботом
- Автоматическая передача subpage конфигов приложений из Remnawave
- ⚡ Fast - React + Vite + TypeScript - ⚡ Fast - React + Vite + TypeScript
## Требования ## Требования
@@ -21,17 +23,19 @@
## Быстрый старт ## Быстрый старт
### Вариант 1: Готовый Docker образ ### Вариант A: Готовый Docker образ
```bash ```bash
# Из GitHub Container Registry
docker pull ghcr.io/bedolaga-dev/bedolaga-cabinet:latest docker pull ghcr.io/bedolaga-dev/bedolaga-cabinet:latest
# Или из Docker Hub или
docker pull bedolaga/bedolaga-cabinet:latest docker pull bedolaga/bedolaga-cabinet:latest
``` ```
### Вариант 2: Сборка из исходников Затем настройте Caddy/Nginx для проксирования (см. раздел "Настройка прокси для production").
### Вариант B: Сборка из исходников
#### 1. Клонирование репозитория #### 1. Клонирование репозитория
@@ -42,12 +46,14 @@ cd bedolaga-cabinet
#### 2. Настройка окружения #### 2. Настройка окружения
Скопируйте `.env.example` в `.env` и настройте переменные: **⚠️ ОБЯЗАТЕЛЬНО:** Скопируйте `.env.example` в `.env` перед запуском!
```bash ```bash
cp .env.example .env cp .env.example .env
``` ```
Docker Compose не запустится без `.env` файла.
**Основные переменные:** **Основные переменные:**
```env ```env
@@ -94,31 +100,134 @@ CABINET_ALLOWED_ORIGINS=http://localhost:3000,https://cabinet.yourdomain.com
## Настройка прокси для production ## Настройка прокси для 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 ```caddyfile
cabinet.yourdomain.com { cabinet.yourdomain.com {
# Проксировать API запросы на backend root * /srv/cabinet
encode gzip
# API запросы на backend
handle /api/* { handle /api/* {
uri strip_prefix /api uri strip_prefix /api
reverse_proxy backend_bot:8080 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 { handle {
reverse_proxy cabinet_frontend:80 reverse_proxy cabinet_frontend:80
} }
} }
``` ```
### Вариант 2: Nginx Nginx (добавьте в существующий конфиг):
Добавьте в конфигурацию Nginx:
```nginx ```nginx
server { server {
listen 443 ssl http2; listen 443 ssl http2;
@@ -127,56 +236,6 @@ server {
ssl_certificate /path/to/cert.pem; ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.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 # API на backend
location /api/ { location /api/ {
rewrite ^/api/(.*) /$1 break; rewrite ^/api/(.*) /$1 break;
@@ -188,54 +247,46 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
# SPA routing # Frontend контейнер (nginx внутри на порту 80)
location / { location / {
try_files $uri $uri/ /index.html; proxy_pass http://cabinet_frontend:80;
} proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Кэширование статики
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
} }
} }
``` ```
## Разработка **Шаг 5:** Перезагрузите Caddy/Nginx:
### Установка зависимостей
```bash ```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 docker-compose.yml:
npm run dev ```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` Затем настройте Caddy/Nginx в той же сети `web`.
### Сборка для production
```bash
npm run build
```
Результат в папке `dist/`
### Проверка типов
```bash
npm run type-check
```
### Линтинг
```bash
npm run lint
```
## Переменные окружения ## Переменные окружения
@@ -290,6 +341,30 @@ bedolaga-cabinet/
2. Контейнеры находятся в одной Docker сети 2. Контейнеры находятся в одной Docker сети
3. Имя сервиса backend в прокси конфигурации правильное 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 авторизация не работает ### Telegram авторизация не работает
1. Проверьте `VITE_TELEGRAM_BOT_USERNAME` - должен быть без `@` 1. Проверьте `VITE_TELEGRAM_BOT_USERNAME` - должен быть без `@`

View File

@@ -1,3 +1,7 @@
# Docker Compose для локальной разработки/тестирования
# Для production используйте готовые образы или настройте Caddy/Nginx
# См. README.md раздел "Настройка прокси для production"
services: services:
cabinet-frontend: cabinet-frontend:
build: build:
@@ -11,10 +15,14 @@ services:
VITE_APP_LOGO: ${VITE_APP_LOGO:-V} VITE_APP_LOGO: ${VITE_APP_LOGO:-V}
container_name: cabinet_frontend container_name: cabinet_frontend
restart: unless-stopped restart: unless-stopped
env_file:
- .env # Загрузить переменные из .env файла
ports: ports:
# Маппинг: <хост-порт>:<контейнер-порт>
# Внутри контейнера nginx слушает на порту 80
- "${CABINET_PORT:-3000}:80" - "${CABINET_PORT:-3000}:80"
healthcheck: 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 interval: 30s
timeout: 3s timeout: 3s
retries: 3 retries: 3

View File

@@ -53,6 +53,22 @@ export interface AdminTicketStats {
closed: number 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 { export interface AdminTicketListResponse {
items: AdminTicket[] items: AdminTicket[]
total: number total: number
@@ -108,6 +124,18 @@ export const adminApi = {
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/priority`, { priority }) const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/priority`, { priority })
return response.data 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 ============ // ============ Dashboard Stats Types ============
@@ -169,12 +197,28 @@ export interface ServerStats {
total_revenue_rubles: number 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 { export interface DashboardStats {
nodes: NodesOverview nodes: NodesOverview
subscriptions: SubscriptionStats subscriptions: SubscriptionStats
financial: FinancialStats financial: FinancialStats
servers: ServerStats servers: ServerStats
revenue_chart: RevenueData[] revenue_chart: RevenueData[]
tariff_stats?: TariffStats
} }
// ============ Dashboard Stats API ============ // ============ Dashboard Stats API ============

View File

@@ -5,6 +5,7 @@ export interface LocalizedText {
ru: string ru: string
zh?: string zh?: string
fa?: string fa?: string
fr?: string
} }
export interface AppButton { export interface AppButton {
@@ -116,4 +117,330 @@ export const adminAppsApi = {
}) })
return response.data 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 }
} }

View File

@@ -1,4 +1,4 @@
import apiClient from './client' import apiClient from './client'
import type { Balance, Transaction, PaymentMethod, PaginatedResponse } from '../types' import type { Balance, Transaction, PaymentMethod, PaginatedResponse } from '../types'
export const balanceApi = { export const balanceApi = {
@@ -27,7 +27,7 @@ export const balanceApi = {
}, },
// Create top-up payment // Create top-up payment
createTopUp: async (amountKopeks: number, paymentMethod: string): Promise<{ createTopUp: async (amountKopeks: number, paymentMethod: string, paymentOption?: string): Promise<{
payment_id: string payment_id: string
payment_url: string payment_url: string
amount_kopeks: number amount_kopeks: number
@@ -35,10 +35,18 @@ export const balanceApi = {
status: string status: string
expires_at: string | null 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, amount_kopeks: amountKopeks,
payment_method: paymentMethod, payment_method: paymentMethod,
}) }
if (paymentOption) {
payload.payment_option = paymentOption
}
const response = await apiClient.post('/cabinet/balance/topup', payload)
return response.data return response.data
}, },

View File

@@ -32,6 +32,8 @@ export interface TariffListItem {
description: string | null description: string | null
is_active: boolean is_active: boolean
is_trial_available: boolean is_trial_available: boolean
is_daily: boolean
daily_price_kopeks: number
traffic_limit_gb: number traffic_limit_gb: number
device_limit: number device_limit: number
tier_level: number tier_level: number
@@ -55,6 +57,7 @@ export interface TariffDetail {
traffic_limit_gb: number traffic_limit_gb: number
device_limit: number device_limit: number
device_price_kopeks: number | null device_price_kopeks: number | null
max_device_limit: number | null
tier_level: number tier_level: number
display_order: number display_order: number
period_prices: PeriodPrice[] period_prices: PeriodPrice[]
@@ -91,6 +94,7 @@ export interface TariffCreateRequest {
traffic_limit_gb?: number traffic_limit_gb?: number
device_limit?: number device_limit?: number
device_price_kopeks?: number device_price_kopeks?: number
max_device_limit?: number
tier_level?: number tier_level?: number
period_prices?: PeriodPrice[] period_prices?: PeriodPrice[]
allowed_squads?: string[] allowed_squads?: string[]
@@ -122,6 +126,7 @@ export interface TariffUpdateRequest {
traffic_limit_gb?: number traffic_limit_gb?: number
device_limit?: number device_limit?: number
device_price_kopeks?: number device_price_kopeks?: number
max_device_limit?: number
tier_level?: number tier_level?: number
display_order?: number display_order?: number
period_prices?: PeriodPrice[] period_prices?: PeriodPrice[]

View File

@@ -53,8 +53,13 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) {
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency() const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency()
const [amount, setAmount] = useState('') const [amount, setAmount] = useState('')
const [error, setError] = useState<string | null>(null) 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 popupRef = useRef<Window | null>(null)
const hasOptions = method.options && method.options.length > 0
const minRubles = method.min_amount_kopeks / 100 const minRubles = method.min_amount_kopeks / 100
const maxRubles = method.max_amount_kopeks / 100 const maxRubles = method.max_amount_kopeks / 100
@@ -121,7 +126,7 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) {
status: string status: string
expires_at: string | null expires_at: string | null
}, unknown, number>({ }, unknown, number>({
mutationFn: (amountKopeks: number) => balanceApi.createTopUp(amountKopeks, method.id), mutationFn: (amountKopeks: number) => balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined),
onSuccess: (data) => { onSuccess: (data) => {
const redirectUrl = data.payment_url || (data as any).invoice_url const redirectUrl = data.payment_url || (data as any).invoice_url
if (redirectUrl) { if (redirectUrl) {
@@ -141,6 +146,7 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) {
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); setError(null) e.preventDefault(); setError(null)
if (hasOptions && !selectedOption) { setError(t('balance.selectPaymentOption', 'Выберите способ оплаты')); return }
const amountCurrency = parseFloat(amount) const amountCurrency = parseFloat(amount)
if (isNaN(amountCurrency) || amountCurrency <= 0) { setError(t('balance.invalidAmount', 'Invalid amount')); return } if (isNaN(amountCurrency) || amountCurrency <= 0) { setError(t('balance.invalidAmount', 'Invalid amount')); return }
const amountRubles = convertToRub(amountCurrency) const amountRubles = convertToRub(amountCurrency)
@@ -180,6 +186,33 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) {
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <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> <div>
<label className="label">{t('balance.amount')} ({currencySymbol})</label> <label className="label">{t('balance.amount')} ({currencySymbol})</label>
<input <input

View File

@@ -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(() => { useEffect(() => {
if (mobileMenuOpen) { if (mobileMenuOpen) {
// Scroll to top so header is visible
window.scrollTo({ top: 0, behavior: 'instant' })
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
} else { } else {
document.body.style.overflow = '' document.body.style.overflow = ''

View File

@@ -218,7 +218,9 @@
"newPackage": "New package", "newPackage": "New package",
"switch": "Change", "switch": "Change",
"switched": "Traffic changed" "switched": "Traffic changed"
} },
"purchasedTraffic": "Purchased Traffic",
"trafficResetAt": "Reset"
}, },
"balance": { "balance": {
"title": "Balance", "title": "Balance",
@@ -260,6 +262,8 @@
}, },
"minAmountError": "Minimum amount: {{amount}} ₽", "minAmountError": "Minimum amount: {{amount}} ₽",
"maxAmountError": "Maximum amount: {{amount}} ₽", "maxAmountError": "Maximum amount: {{amount}} ₽",
"paymentOption": "Payment option",
"selectPaymentOption": "Select payment option",
"paymentMethods": { "paymentMethods": {
"yookassa": { "yookassa": {
"name": "YooKassa (Bank Card)", "name": "YooKassa (Bank Card)",
@@ -272,6 +276,34 @@
"telegram_stars": { "telegram_stars": {
"name": "Telegram Stars", "name": "Telegram Stars",
"description": "Pay with 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", "user": "User",
"joined": "Joined", "joined": "Joined",
"hasPaid": "Has Paid", "hasPaid": "Has Paid",
"status": {
"pending": "Pending",
"paid": "Paid"
},
"earningsHistory": "Earnings History", "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": { "support": {
"title": "Support", "title": "Support",
@@ -514,7 +557,23 @@
"subscription": "Subscription", "subscription": "Subscription",
"connect": "Connect", "connect": "Connect",
"additional": "Additional" "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": { "tickets": {
"title": "Ticket Management", "title": "Ticket Management",
@@ -534,7 +593,23 @@
"userLabel": "User", "userLabel": "User",
"adminLabel": "Admin", "adminLabel": "Admin",
"replyPlaceholder": "Type your reply...", "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": { "tariffs": {
"title": "Tariff Management", "title": "Tariff Management",
@@ -656,6 +731,17 @@
"available": "Available", "available": "Available",
"withConnections": "With Connections", "withConnections": "With Connections",
"revenue": "Revenue" "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": { "profile": {

File diff suppressed because it is too large Load Diff

View File

@@ -218,7 +218,9 @@
"newPackage": "Новый пакет", "newPackage": "Новый пакет",
"switch": "Изменить", "switch": "Изменить",
"switched": "Трафик изменён" "switched": "Трафик изменён"
} },
"purchasedTraffic": "Докупленный трафик",
"trafficResetAt": "Сброс"
}, },
"balance": { "balance": {
"title": "Баланс", "title": "Баланс",
@@ -260,6 +262,8 @@
}, },
"minAmountError": "Минимальная сумма: {{amount}} ₽", "minAmountError": "Минимальная сумма: {{amount}} ₽",
"maxAmountError": "Максимальная сумма: {{amount}} ₽", "maxAmountError": "Максимальная сумма: {{amount}} ₽",
"paymentOption": "Способ оплаты",
"selectPaymentOption": "Выберите способ оплаты",
"paymentMethods": { "paymentMethods": {
"yookassa": { "yookassa": {
"name": "ЮKassa (Банковская карта)", "name": "ЮKassa (Банковская карта)",
@@ -272,6 +276,34 @@
"telegram_stars": { "telegram_stars": {
"name": "Telegram Stars", "name": "Telegram Stars",
"description": "Оплата через 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": "Пользователь", "user": "Пользователь",
"joined": "Присоединился", "joined": "Присоединился",
"hasPaid": "Оплатил", "hasPaid": "Оплатил",
"status": {
"pending": "Ожидание",
"paid": "Оплачено"
},
"earningsHistory": "История начислений", "earningsHistory": "История начислений",
"reason": "Причина" "reason": "Причина",
"reasons": {
"referral_registration_pending": "Регистрация реферала",
"referral_registration": "Регистрация реферала",
"referral_first_payment": "Первая оплата реферала",
"referral_subscription_renewal": "Продление подписки реферала",
"referral_bonus": "Бонус за реферала"
}
}, },
"support": { "support": {
"title": "Поддержка", "title": "Поддержка",
@@ -514,7 +557,23 @@
"subscription": "Подписка", "subscription": "Подписка",
"connect": "Подключение", "connect": "Подключение",
"additional": "Дополнительно" "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": { "tickets": {
"title": "Управление тикетами", "title": "Управление тикетами",
@@ -534,7 +593,23 @@
"userLabel": "Пользователь", "userLabel": "Пользователь",
"adminLabel": "Админ", "adminLabel": "Админ",
"replyPlaceholder": "Введите ваш ответ...", "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": { "tariffs": {
"title": "Управление тарифами", "title": "Управление тарифами",
@@ -656,6 +731,17 @@
"available": "Доступных", "available": "Доступных",
"withConnections": "С подключениями", "withConnections": "С подключениями",
"revenue": "Доход" "revenue": "Доход"
},
"tariffs": {
"title": "Статистика по тарифам",
"subtitle": "Распределение подписок по тарифам",
"tariffName": "Тариф",
"activeSubscriptions": "Активных",
"trialSubscriptions": "Триал",
"purchasedToday": "Сегодня",
"purchasedWeek": "Неделя",
"purchasedMonth": "Месяц",
"noTariffs": "Нет тарифов"
} }
}, },
"profile": { "profile": {

View File

@@ -1,4 +1,4 @@
{ {
"common": { "common": {
"loading": "加载中...", "loading": "加载中...",
"error": "错误", "error": "错误",
@@ -165,7 +165,20 @@
"currentBalance": "当前余额", "currentBalance": "当前余额",
"topUp": "充值", "topUp": "充值",
"topUpBalance": "充值余额", "topUpBalance": "充值余额",
"paymentMethods": "支付方式", "paymentMethods": {
"yookassa": {
"name": "银行卡",
"description": "通过银行卡支付"
},
"cryptobot": {
"name": "CryptoBot",
"description": "通过CryptoBot加密货币支付"
},
"telegram_stars": {
"name": "Telegram Stars",
"description": "通过Telegram Stars支付"
}
},
"transactionHistory": "交易记录", "transactionHistory": "交易记录",
"noTransactions": "暂无交易", "noTransactions": "暂无交易",
"date": "日期", "date": "日期",
@@ -199,21 +212,7 @@
} }
}, },
"minAmountError": "最小金额:{{amount}}", "minAmountError": "最小金额:{{amount}}",
"maxAmountError": "最大金额:{{amount}}", "maxAmountError": "最大金额:{{amount}}"
"paymentMethods": {
"yookassa": {
"name": "银行卡",
"description": "通过银行卡支付"
},
"cryptobot": {
"name": "CryptoBot",
"description": "通过CryptoBot加密货币支付"
},
"telegram_stars": {
"name": "Telegram Stars",
"description": "通过Telegram Stars支付"
}
}
}, },
"referral": { "referral": {
"title": "推荐计划", "title": "推荐计划",
@@ -242,7 +241,18 @@
"joined": "加入时间", "joined": "加入时间",
"hasPaid": "已付款", "hasPaid": "已付款",
"earningsHistory": "收益记录", "earningsHistory": "收益记录",
"reason": "原因" "reason": "原因",
"status": {
"pending": "待定",
"paid": "已支付"
},
"reasons": {
"referral_registration_pending": "推荐用户注册",
"referral_registration": "推荐用户注册",
"referral_first_payment": "推荐用户首次付款",
"referral_subscription_renewal": "推荐用户续订",
"referral_bonus": "推荐奖金"
}
}, },
"support": { "support": {
"title": "支持", "title": "支持",
@@ -462,7 +472,23 @@
"userLabel": "用户", "userLabel": "用户",
"adminLabel": "管理员", "adminLabel": "管理员",
"replyPlaceholder": "输入您的回复...", "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": { "tariffs": {
"title": "套餐管理", "title": "套餐管理",
@@ -709,4 +735,4 @@
} }
} }
} }
} }

View File

@@ -1,7 +1,17 @@
import { useState } from 'react' import { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' 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 // Icons
const AppsIcon = () => ( const AppsIcon = () => (
@@ -52,6 +62,37 @@ const StarIcon = ({ filled }: { filled: boolean }) => (
</svg> </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> = { const PLATFORM_LABELS: Record<string, string> = {
ios: 'iOS', ios: 'iOS',
android: 'Android', android: 'Android',
@@ -389,12 +430,62 @@ export default function AdminApps() {
const [selectedPlatform, setSelectedPlatform] = useState<string>('ios') const [selectedPlatform, setSelectedPlatform] = useState<string>('ios')
const [editingApp, setEditingApp] = useState<{ app: AppDefinition; isNew: boolean } | null>(null) const [editingApp, setEditingApp] = useState<{ app: AppDefinition; isNew: boolean } | null>(null)
const [copyTarget, setCopyTarget] = useState<{ appId: string; platform: string } | 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], queryKey: ['admin-apps', selectedPlatform],
queryFn: () => adminAppsApi.getPlatformApps(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({ const createMutation = useMutation({
mutationFn: ({ platform, app }: { platform: string; app: AppDefinition }) => mutationFn: ({ platform, app }: { platform: string; app: AppDefinition }) =>
adminAppsApi.createApp(platform, app), 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) => { const handleMoveUp = (index: number) => {
if (!apps || index === 0) return if (!apps || index === 0) return
const newOrder = [...apps] const newOrder = [...apps]
@@ -468,11 +638,60 @@ export default function AdminApps() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center gap-3"> <div className="flex items-center justify-between flex-wrap gap-3">
<AppsIcon /> <div className="flex items-center gap-3">
<h1 className="text-2xl sm:text-3xl font-bold text-dark-50">{t('admin.apps.title')}</h1> <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> </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 */} {/* Platform Tabs */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{PLATFORMS.map((platform) => ( {PLATFORMS.map((platform) => (
@@ -490,16 +709,44 @@ export default function AdminApps() {
))} ))}
</div> </div>
{/* Add App Button */} {/* Action Buttons - Only show when not in RemnaWave mode */}
<div className="flex justify-end"> {!useRemnaWave && (
<button <div className="flex flex-wrap gap-3 justify-between items-center">
onClick={() => setEditingApp({ app: createEmptyApp(selectedPlatform), isNew: true })} <div className="flex gap-2">
className="btn-primary flex items-center gap-2" <input
> ref={fileInputRef}
<PlusIcon /> type="file"
{t('admin.apps.addApp')} accept=".json"
</button> onChange={handleFileSelect}
</div> 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 */} {/* Apps List */}
{isLoading ? ( {isLoading ? (
@@ -513,23 +760,25 @@ export default function AdminApps() {
key={app.id} key={app.id}
className="card flex items-center gap-4 p-4" className="card flex items-center gap-4 p-4"
> >
{/* Reorder buttons */} {/* Reorder buttons - Only show when not in RemnaWave mode */}
<div className="flex flex-col gap-1"> {!useRemnaWave && (
<button <div className="flex flex-col gap-1">
onClick={() => handleMoveUp(index)} <button
disabled={index === 0} onClick={() => handleMoveUp(index)}
className="p-1 text-dark-400 hover:text-dark-200 disabled:opacity-30 disabled:cursor-not-allowed" disabled={index === 0}
> className="p-1 text-dark-400 hover:text-dark-200 disabled:opacity-30 disabled:cursor-not-allowed"
<ChevronUpIcon /> >
</button> <ChevronUpIcon />
<button </button>
onClick={() => handleMoveDown(index)} <button
disabled={index === apps.length - 1} onClick={() => handleMoveDown(index)}
className="p-1 text-dark-400 hover:text-dark-200 disabled:opacity-30 disabled:cursor-not-allowed" disabled={index === apps.length - 1}
> className="p-1 text-dark-400 hover:text-dark-200 disabled:opacity-30 disabled:cursor-not-allowed"
<ChevronDownIcon /> >
</button> <ChevronDownIcon />
</div> </button>
</div>
)}
{/* App Info */} {/* App Info */}
<div className="flex-1 min-w-0"> <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 className="text-xs text-dark-400 mt-1">{app.urlScheme}</div>
</div> </div>
{/* Actions */} {/* Actions - Only show when not in RemnaWave mode */}
<div className="flex items-center gap-2"> {!useRemnaWave && (
<button <div className="flex items-center gap-2">
onClick={() => setCopyTarget({ appId: app.id, platform: selectedPlatform })} <button
className="p-2 text-dark-400 hover:text-dark-200 hover:bg-dark-700 rounded" onClick={() => setCopyTarget({ appId: app.id, platform: selectedPlatform })}
title={t('admin.apps.copyTo')} className="p-2 text-dark-400 hover:text-dark-200 hover:bg-dark-700 rounded"
> title={t('admin.apps.copyTo')}
<CopyIcon /> >
</button> <CopyIcon />
<button </button>
onClick={() => setEditingApp({ app, isNew: false })} <button
className="p-2 text-dark-400 hover:text-dark-200 hover:bg-dark-700 rounded" onClick={() => setEditingApp({ app, isNew: false })}
title={t('common.edit')} className="p-2 text-dark-400 hover:text-dark-200 hover:bg-dark-700 rounded"
> title={t('common.edit')}
<EditIcon /> >
</button> <EditIcon />
<button </button>
onClick={() => handleDelete(app.id)} <button
className="p-2 text-error-400 hover:text-error-300 hover:bg-error-500/10 rounded" onClick={() => handleDelete(app.id)}
title={t('common.delete')} className="p-2 text-error-400 hover:text-error-300 hover:bg-error-500/10 rounded"
> title={t('common.delete')}
<TrashIcon /> >
</button> <TrashIcon />
</div> </button>
</div>
)}
</div> </div>
))} ))}
</div> </div>
@@ -624,6 +875,149 @@ export default function AdminApps() {
</div> </div>
</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> </div>
) )
} }

View File

@@ -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 last7Days = data.slice(-7)
const maxValue = Math.max(...last7Days.map(d => d.amount_rubles), 1)
return ( return (
<div className="h-48"> <div className="space-y-3">
<div className="flex items-end justify-between h-36 gap-2"> {last7Days.map((item, index) => {
{last7Days.map((item, index) => { const percentage = (item.amount_rubles / maxValue) * 100
const height = (item.amount_rubles / maxValue) * 100 const date = new Date(item.date)
const date = new Date(item.date) const dayName = date.toLocaleDateString('ru-RU', { weekday: 'short' })
const dayName = date.toLocaleDateString('ru-RU', { weekday: 'short' }) const dayNum = date.getDate()
return ( return (
<div key={index} className="flex-1 flex flex-col items-center gap-2"> <div key={index} className="group">
<div <div className="flex items-center justify-between mb-1">
className="w-full bg-accent-500/80 rounded-t-lg hover:bg-accent-500 transition-colors cursor-pointer group relative" <span className="text-sm text-dark-300 font-medium capitalize">{dayName}, {dayNum}</span>
style={{ height: `${Math.max(height, 4)}%` }} <span className="text-sm font-semibold text-dark-100">{formatAmount(item.amount_rubles)} {currencySymbol}</span>
>
<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>
</div> </div>
) <div className="h-3 bg-dark-700/50 rounded-full overflow-hidden">
})} <div
</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> </div>
) )
} }
@@ -497,6 +497,58 @@ export default function AdminDashboard() {
</div> </div>
</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> </div>
) )
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { adminApi, AdminTicket, AdminTicketDetail, AdminTicketMessage } from '../api/admin' import { adminApi, AdminTicket, AdminTicketDetail, AdminTicketMessage } from '../api/admin'
@@ -86,6 +86,7 @@ export default function AdminTickets() {
const [statusFilter, setStatusFilter] = useState<string>('') const [statusFilter, setStatusFilter] = useState<string>('')
const [replyText, setReplyText] = useState('') const [replyText, setReplyText] = useState('')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [showSettings, setShowSettings] = useState(false)
const { data: stats } = useQuery({ const { data: stats } = useQuery({
queryKey: ['admin-ticket-stats'], queryKey: ['admin-ticket-stats'],
@@ -162,7 +163,19 @@ export default function AdminTickets() {
return ( return (
<div className="space-y-6"> <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 */}
{stats && ( {stats && (
@@ -380,6 +393,198 @@ export default function AdminTickets() {
) : null} ) : null}
</div> </div>
</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> </div>
) )
} }

View File

@@ -198,7 +198,6 @@ export default function Balance() {
) )
})} })}
</div> </div>
<p className="mt-4 text-sm text-dark-500">{t('balance.useBot')}</p>
</div> </div>
)} )}
@@ -212,7 +211,14 @@ export default function Balance() {
</div> </div>
) : transactions?.items && transactions.items.length > 0 ? ( ) : transactions?.items && transactions.items.length > 0 ? (
<div className="space-y-3"> <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 <div
key={tx.id} key={tx.id}
className="flex items-center justify-between p-4 rounded-xl bg-dark-800/30 border border-dark-700/30" 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 className="text-sm text-dark-400">{tx.description}</div>
)} )}
</div> </div>
<div className={`text-lg font-semibold ${tx.amount_kopeks > 0 ? 'text-success-400' : 'text-error-400'}`}> <div className={`text-lg font-semibold ${colorClass}`}>
{tx.amount_kopeks > 0 ? '+' : ''}{formatAmount(tx.amount_rubles)} {currencySymbol} {sign}{formatAmount(displayAmount)} {currencySymbol}
</div> </div>
</div> </div>
))} )
})}
</div> </div>
) : ( ) : (
<div className="text-center py-12"> <div className="text-center py-12">

View File

@@ -198,9 +198,9 @@ export default function Referral() {
</div> </div>
</div> </div>
{ref.has_paid ? ( {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> </div>
))} ))}
@@ -232,7 +232,7 @@ export default function Referral() {
{earning.referral_first_name || earning.referral_username || 'Referral'} {earning.referral_first_name || earning.referral_username || 'Referral'}
</div> </div>
<div className="text-xs text-dark-500 mt-0.5"> <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> </div>
<div className="text-success-400 font-semibold"> <div className="text-success-400 font-semibold">

View File

@@ -220,7 +220,9 @@ export default function Subscription() {
if (!selectedTariff) { if (!selectedTariff) {
throw new Error('Tariff not selected') 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 const trafficGb = useCustomTraffic && selectedTariff.custom_traffic_enabled ? customTrafficGb : undefined
return subscriptionApi.purchaseTariff(selectedTariff.id, days, trafficGb) return subscriptionApi.purchaseTariff(selectedTariff.id, days, trafficGb)
}, },
@@ -484,7 +486,7 @@ export default function Subscription() {
{/* Purchased Traffic Packages */} {/* Purchased Traffic Packages */}
{subscription.traffic_purchases && subscription.traffic_purchases.length > 0 && ( {subscription.traffic_purchases && subscription.traffic_purchases.length > 0 && (
<div className="mb-6"> <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"> <div className="space-y-3">
{subscription.traffic_purchases.map((purchase) => ( {subscription.traffic_purchases.map((purchase) => (
<div key={purchase.id} className="p-3 rounded-lg bg-dark-800/50 border border-dark-700/50"> <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> </svg>
<span className="text-base font-semibold text-dark-100">{purchase.traffic_gb} ГБ</span> <span className="text-base font-semibold text-dark-100">{purchase.traffic_gb} ГБ</span>
</div> </div>
<div className="text-sm text-dark-400"> <div className="flex flex-col items-end gap-1">
{purchase.days_remaining === 0 ? ( <div className="text-sm text-dark-400">
<span className="text-orange-500">Истекает сегодня</span> {purchase.days_remaining === 0 ? (
) : purchase.days_remaining === 1 ? ( <span className="text-orange-500">Истекает сегодня</span>
<span className="text-orange-400">Остался 1 день</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> ) : (
)} <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> </div>
<div className="relative h-1.5 bg-dark-700 rounded-full overflow-hidden"> <div className="relative h-1.5 bg-dark-700 rounded-full overflow-hidden">
@@ -932,6 +946,20 @@ export default function Subscription() {
</h2> </h2>
</div> </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 */} {/* Switch Tariff Preview Modal */}
{switchTariffId && ( {switchTariffId && (
<div ref={switchModalRef} className="mb-6 bg-dark-800/50 rounded-xl p-5 space-y-4"> <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 ? ( {!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"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{[...tariffs] {[...tariffs]
.sort((a, b) => { .sort((a, b) => {
@@ -1031,6 +1076,8 @@ export default function Subscription() {
.map((tariff) => { .map((tariff) => {
const isCurrentTariff = tariff.is_current || tariff.id === subscription?.tariff_id const isCurrentTariff = tariff.is_current || tariff.id === subscription?.tariff_id
const canSwitch = subscription && subscription.tariff_id && !isCurrentTariff && !subscription.is_trial const canSwitch = subscription && subscription.tariff_id && !isCurrentTariff && !subscription.is_trial
// Если есть подписка БЕЗ tariff_id (классическая) - разрешить выбрать тариф
const isLegacySubscription = subscription && !subscription.is_trial && !subscription.tariff_id
return ( return (
<div <div
@@ -1068,18 +1115,37 @@ export default function Subscription() {
{(() => { {(() => {
// Daily tariff price (is_daily + daily_price_kopeks) // Daily tariff price (is_daily + daily_price_kopeks)
const dailyPrice = tariff.daily_price_kopeks ?? tariff.price_per_day_kopeks ?? 0 const dailyPrice = tariff.daily_price_kopeks ?? tariff.price_per_day_kopeks ?? 0
const originalDailyPrice = tariff.original_daily_price_kopeks || 0
if (dailyPrice > 0) { if (dailyPrice > 0) {
return ( return (
<span> <span className="flex items-center gap-2">
<span className="text-accent-400 font-medium">{formatPrice(dailyPrice)}</span> / день <span className="text-accent-400 font-medium">{formatPrice(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> </span>
) )
} }
// Period-based price // Period-based price
if (tariff.periods.length > 0) { if (tariff.periods.length > 0) {
const firstPeriod = tariff.periods[0]
const hasDiscount = firstPeriod?.original_price_kopeks && firstPeriod.original_price_kopeks > firstPeriod.price_kopeks
return ( return (
<span> <span className="flex items-center gap-2 flex-wrap">
{t('subscription.from')} <span className="text-accent-400 font-medium">{formatPrice(tariff.periods[0]?.price_kopeks || 0)}</span> <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> </span>
) )
} }
@@ -1108,34 +1174,45 @@ export default function Subscription() {
{t('subscription.extend')} {t('subscription.extend')}
</button> </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) */ /* No subscription or trial - purchase button */
canSwitch ? ( <button
<button onClick={() => {
onClick={() => setSwitchTariffId(tariff.id)} setSelectedTariff(tariff)
className="btn-secondary flex-1 py-2 text-sm" setSelectedTariffPeriod(tariff.periods[0] || null)
> setShowTariffPurchase(true)
{t('subscription.switchTariff.switch')} }}
</button> className="btn-primary flex-1 py-2 text-sm"
) : ( >
/* No subscription or trial - purchase button */ {t('subscription.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> </div>
) )
})} })}
</div> </div>
</>
) : selectedTariff && ( ) : selectedTariff && (
/* Tariff Purchase Form */ /* Tariff Purchase Form */
<div ref={tariffPurchaseRef} className="space-y-6"> <div ref={tariffPurchaseRef} className="space-y-6">
@@ -1260,14 +1337,24 @@ export default function Subscription() {
setSelectedTariffPeriod(period) setSelectedTariffPeriod(period)
setUseCustomDays(false) 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 selectedTariffPeriod?.days === period.days && !useCustomDays
? 'border-accent-500 bg-accent-500/10' ? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 hover:border-dark-600 bg-dark-800/30' : '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-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> <div className="text-xs text-dark-500 mt-1">{formatPrice(period.price_per_month_kopeks)}/{t('subscription.month')}</div>
</button> </button>
))} ))}

View File

@@ -120,6 +120,8 @@ function MessageMedia({ message, t }: { message: TicketMessage; t: (key: string)
} }
export default function Support() { export default function Support() {
console.log('[Support] Component loaded - VERSION 2024-01-16-v3')
const { t } = useTranslation() const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [selectedTicket, setSelectedTicket] = useState<TicketDetail | null>(null) const [selectedTicket, setSelectedTicket] = useState<TicketDetail | null>(null)
@@ -269,17 +271,63 @@ export default function Support() {
// If tickets are disabled, show redirect message // If tickets are disabled, show redirect message
if (supportConfig && !supportConfig.tickets_enabled) { if (supportConfig && !supportConfig.tickets_enabled) {
console.log('[Support] Tickets disabled, config:', supportConfig)
const getSupportMessage = () => { const getSupportMessage = () => {
console.log('[Support] Getting support message for type:', supportConfig.support_type)
if (supportConfig.support_type === 'profile') { if (supportConfig.support_type === 'profile') {
const supportUsername = supportConfig.support_username || '@support'
console.log('[Support] Opening profile:', supportUsername)
return { return {
title: t('support.ticketsDisabled'), title: t('support.ticketsDisabled'),
message: t('support.useProfile'), message: t('support.contactSupport', { username: supportUsername }),
buttonText: t('support.goToProfile'), buttonText: t('support.contactUs'),
buttonAction: () => { buttonAction: () => {
console.log('[Support] Button clicked, opening:', supportUsername)
const webApp = window.Telegram?.WebApp 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' const supportUsername = supportConfig.support_username || '@support'
console.log('[Support] Fallback: Opening profile:', supportUsername)
return { return {
title: t('support.ticketsDisabled'), title: t('support.ticketsDisabled'),
message: t('support.contactSupport', { username: supportUsername }), message: t('support.contactSupport', { username: supportUsername }),
buttonText: t('support.contactUs'), buttonText: t('support.contactUs'),
buttonAction: () => { buttonAction: () => {
console.log('[Support] Fallback button clicked, opening:', supportUsername)
const webApp = window.Telegram?.WebApp const webApp = window.Telegram?.WebApp
const url = supportUsername.startsWith('@')
? `https://t.me/${supportUsername.slice(1)}` // Extract username without @
: `https://t.me/${supportUsername}` const username = supportUsername.startsWith('@')
? supportUsername.slice(1)
: supportUsername
const webUrl = `https://t.me/${username}`
console.log('[Support] Fallback opening URL:', webUrl)
if (webApp?.openTelegramLink) { 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 { } else {
window.open(url, '_blank') console.log('[Support] Fallback using window.open')
window.open(webUrl, '_blank')
} }
}, },
} }

View File

@@ -214,6 +214,14 @@ export interface TariffPeriod {
price_label: string price_label: string
price_per_month_kopeks: number price_per_month_kopeks: number
price_per_month_label: string 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 { export interface TariffServer {
@@ -254,6 +262,14 @@ export interface Tariff {
// Daily tariff options // Daily tariff options
is_daily?: boolean is_daily?: boolean
daily_price_kopeks?: number 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 { export interface TariffsPurchaseOptions {
@@ -348,6 +364,12 @@ export interface Transaction {
completed_at: string | null completed_at: string | null
} }
export interface PaymentMethodOption {
id: string
name: string
description?: string | null
}
export interface PaymentMethod { export interface PaymentMethod {
id: string id: string
name: string name: string
@@ -355,6 +377,7 @@ export interface PaymentMethod {
min_amount_kopeks: number min_amount_kopeks: number
max_amount_kopeks: number max_amount_kopeks: number
is_available: boolean is_available: boolean
options?: PaymentMethodOption[] | null
} }
// Referral types // Referral types