diff --git a/.env.example b/.env.example index 81b7c42..4e88994 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,8 @@ # ===== BUILD-TIME VARIABLES ===== -# These are baked into the frontend at build time +# These variables are baked into the frontend JavaScript at build time +# If you change them, you need to rebuild: docker compose up -d --build -# API URL (relative path for same-origin, or full URL for external) +# API URL - How the browser will access the backend # Same server (with proxy): /api # Remote server (direct): https://api.yourbot.com/cabinet VITE_API_URL=/api @@ -9,7 +10,7 @@ VITE_API_URL=/api # Telegram bot username for Login Widget (without @) VITE_TELEGRAM_BOT_USERNAME=your_bot_username -# ===== BRANDING ===== +# ===== BRANDING (optional) ===== # App name displayed in header and browser tab VITE_APP_NAME=VPN Cabinet @@ -17,21 +18,7 @@ VITE_APP_NAME=VPN Cabinet VITE_APP_LOGO=V # ===== RUNTIME VARIABLES ===== -# These are used by Caddy at runtime -# Backend URL for reverse proxy (only if using proxy mode with VITE_API_URL=/api) -# Development: http://host.docker.internal:8080 -# Production (same network): http://remnawave_bot:8080 -# Remote server: https://api.yourbot.com -BACKEND_URL=https://api.yourbot.com - -# Port to expose the frontend +# Port to expose the frontend on host machine +# Default: 3000 (http://localhost:3000) CABINET_PORT=3000 - -# ===== PRODUCTION ONLY ===== - -# Domain for HTTPS (Caddy will auto-obtain certificates) -DOMAIN=cabinet.yourdomain.com - -# HTTPS port (usually 443) -CABINET_HTTPS_PORT=443 diff --git a/.github/ISSUE_TEMPLATE/bug-bedolage.md b/.github/ISSUE_TEMPLATE/bug-bedolage.md new file mode 100644 index 0000000..fd68c55 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-bedolage.md @@ -0,0 +1,42 @@ +--- +name: Bug Bedolage +about: Создать баг-репорт «Бедолаге» +title: "[BUG] Заголовок" +labels: '' +assignees: '' + +--- + +## Описание / Конфигурация + +Кратко описываем текущую проблему. + +Указываем настройки из `.env` конфига (если таковые имеются): + +```yml + +``` + +## Шаги для воспроизведения + +1. +2. +3. + +### Фактический результат + +* +* +* + +### Скрины поведения + +Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию. + +## Ожидаемое поведение + +Опишите, как должно работать. + +## Дополнительная информация + +Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию. diff --git a/.github/ISSUE_TEMPLATE/feat-bedolage.md b/.github/ISSUE_TEMPLATE/feat-bedolage.md new file mode 100644 index 0000000..37cc450 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feat-bedolage.md @@ -0,0 +1,32 @@ +--- +name: Feat Bedolage +about: Создать запрос на фичу «Бедолаге» +title: "[FEAT] Заголовок" +labels: '' +assignees: '' + +--- + +## Описание / Конфигурация + +Кратко описываем что хотим. + +Указываем настройки из `.env` конфига (если таковые к месту): + +```yml + +``` + +## Текущее поведение + +### Скрины поведения + +Копируем изображение и вставляем прям сюда, если есть. Если нет — удаляем секцию. + +## Желаемое поведение + +Опишите, как хотелось бы, чтобы было. + +## Дополнительная информация + +Добавляем сюда дополнительную инфу, если есть. Если нет — удаляем секцию. diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index 5ed2a26..70a9c07 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -33,6 +33,7 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} + type=sha type=raw,value=latest,enable={{is_default_branch}} - name: Build and push to Docker Hub @@ -47,6 +48,6 @@ jobs: build-args: | VITE_API_URL=/api VITE_TELEGRAM_BOT_USERNAME= - VITE_APP_NAME=Bedolaga Cabinet + VITE_APP_NAME=Cabinet VITE_APP_LOGO=V platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 4695a1c..57fbd02 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -45,7 +45,7 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} - type=sha,prefix={{branch}}- + type=sha type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image @@ -60,5 +60,5 @@ jobs: build-args: | VITE_API_URL=/api VITE_TELEGRAM_BOT_USERNAME= - VITE_APP_NAME=Bedolaga Cabinet + VITE_APP_NAME=Cabinet VITE_APP_LOGO=V diff --git a/README.md b/README.md index 392e799..10209ba 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Bedolaga Cabinet - Web Interface -Современный веб-интерфейс личного кабинета для VPN бота на базе [Remnawave Bedolaga Telegram Bot](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot) +Современный веб-интерфейс личного кабинета для VPN бота на базе [Remnawave Bedolaga Telegram Bot](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot). ## Возможности @@ -11,6 +11,8 @@ - 🌐 Мультиязычность (EN/RU) - 📱 Адаптивный дизайн - 🎨 Настраиваемый брендинг +- Админпанель для управлени ботом +- Автоматическая передача subpage конфигов приложений из Remnawave - ⚡ Fast - React + Vite + TypeScript ## Требования @@ -21,17 +23,19 @@ ## Быстрый старт -### Вариант 1: Готовый Docker образ +### Вариант A: Готовый Docker образ ```bash -# Из GitHub Container Registry docker pull ghcr.io/bedolaga-dev/bedolaga-cabinet:latest -# Или из Docker Hub +или + docker pull bedolaga/bedolaga-cabinet:latest ``` -### Вариант 2: Сборка из исходников +Затем настройте Caddy/Nginx для проксирования (см. раздел "Настройка прокси для production"). + +### Вариант B: Сборка из исходников #### 1. Клонирование репозитория @@ -42,12 +46,14 @@ cd bedolaga-cabinet #### 2. Настройка окружения -Скопируйте `.env.example` в `.env` и настройте переменные: +**⚠️ ОБЯЗАТЕЛЬНО:** Скопируйте `.env.example` в `.env` перед запуском! ```bash cp .env.example .env ``` +Docker Compose не запустится без `.env` файла. + **Основные переменные:** ```env @@ -94,31 +100,134 @@ CABINET_ALLOWED_ORIGINS=http://localhost:3000,https://cabinet.yourdomain.com ## Настройка прокси для production -Frontend раздает только статические файлы. Для работы с API нужно настроить reverse proxy, который будет проксировать запросы `/api/*` на backend бота. +Frontend - это статические файлы (HTML, JS, CSS). Для работы нужно: +1. Раздавать статику через веб-сервер +2. Проксировать `/api/*` запросы на backend бота -### Вариант 1: Caddy +> **💡 Важно:** Docker контейнер из этого репозитория содержит nginx, который слушает на **внутреннем порту 80**. +> Это НЕ хост-порт! Выберите один из вариантов ниже в зависимости от вашей инфраструктуры. -Добавьте в ваш Caddyfile: +### Вариант 1: Caddy раздает статику напрямую + +**✅ Рекомендуется** - без лишних слоев прокси, максимальная производительность. + +Соберите frontend и примонтируйте в Caddy: + +```bash +# Соберите образ или скопируйте dist из контейнера +docker compose build +docker create --name temp_cabinet cabinet_frontend +docker cp temp_cabinet:/usr/share/nginx/html ./cabinet-dist +docker rm temp_cabinet +``` + +Caddyfile: ```caddyfile cabinet.yourdomain.com { - # Проксировать API запросы на backend + root * /srv/cabinet + encode gzip + + # API запросы на backend handle /api/* { uri strip_prefix /api reverse_proxy backend_bot:8080 } - # Остальное - на frontend контейнер + # Статические файлы + handle { + try_files {path} /index.html + file_server + } +} +``` + +docker-compose.yml для Caddy: + +```yaml +services: + caddy: + image: caddy:2-alpine + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - ./cabinet-dist:/srv/cabinet:ro + - caddy_data:/data + ports: + - "80:80" + - "443:443" + networks: + - bot_network +``` + +### Вариант 2: Проксирование на frontend контейнер + +Если хотите использовать готовый Docker контейнер с nginx внутри. + +**⚠️ Важно:** +- Порт `80` в примерах - это **внутренний порт контейнера** (nginx внутри), не хост-порт! +- Контейнеры должны быть в одной Docker сети для связи друг с другом +- Имена контейнеров используются как DNS внутри Docker сети + +#### A. Если у вас УЖЕ запущен Caddy/Nginx в Docker: + +**Шаг 1:** Узнайте имя Docker сети вашего Caddy/Nginx: + +```bash +# Посмотреть сети +docker network ls + +# Или узнать сеть конкретного контейнера +docker inspect <имя_caddy_контейнера> | grep NetworkMode +``` + +**Шаг 2:** Создайте docker-compose.yml для frontend: + +```yaml +services: + cabinet-frontend: + image: ghcr.io/bedolaga-dev/bedolaga-cabinet:latest + container_name: cabinet_frontend + restart: unless-stopped + # НЕ открываем порты на хосте! Только внутри Docker сети + networks: + - bot_network + +networks: + bot_network: + external: true # Используем существующую сеть + name: remnawave-bedolaga-telegram-bot_bot_network # Пример для bedolaga bot +``` + +**Важно:** Замените имя сети на вашу: +- Если у вас bot + caddy: используйте сеть бота (обычно `<название_проекта>_bot_network`) +- Если отдельный Caddy: узнайте через `docker network ls` +- Если используете Traefik: обычно `traefik` или `web` + +**Шаг 3:** Запустите frontend: + +```bash +docker compose up -d +``` + +**Шаг 4:** Добавьте в конфигурацию Caddy/Nginx: + +Caddy проксирует на контейнер: +```caddyfile +cabinet.yourdomain.com { + # API на backend + handle /api/* { + uri strip_prefix /api + reverse_proxy backend_bot:8080 + } + + # Frontend контейнер (nginx внутри на порту 80) handle { reverse_proxy cabinet_frontend:80 } } ``` -### Вариант 2: Nginx - -Добавьте в конфигурацию Nginx: - +Nginx (добавьте в существующий конфиг): ```nginx server { listen 443 ssl http2; @@ -127,56 +236,6 @@ server { ssl_certificate /path/to/cert.pem; ssl_certificate_key /path/to/key.pem; - # API запросы проксируем на backend - location /api/ { - rewrite ^/api/(.*) /$1 break; - proxy_pass http://backend_bot:8080; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # Frontend контейнер - location / { - proxy_pass http://cabinet_frontend:80; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } -} -``` - -### Вариант 3: Статика + прямое проксирование - -Если хотите раздавать статику напрямую без Docker: - -```bash -# Соберите проект -npm install -npm run build - -# Скопируйте dist на сервер -scp -r dist/* user@server:/var/www/cabinet/ -``` - -Конфигурация Nginx для статики: - -```nginx -server { - listen 443 ssl http2; - server_name cabinet.yourdomain.com; - - ssl_certificate /path/to/cert.pem; - ssl_certificate_key /path/to/key.pem; - - root /var/www/cabinet; - index index.html; - - # Gzip - gzip on; - gzip_types text/plain text/css application/json application/javascript text/xml application/xml; - # API на backend location /api/ { rewrite ^/api/(.*) /$1 break; @@ -188,54 +247,46 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # SPA routing + # Frontend контейнер (nginx внутри на порту 80) location / { - try_files $uri $uri/ /index.html; - } - - # Кэширование статики - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { - expires 1y; - add_header Cache-Control "public, immutable"; + proxy_pass http://cabinet_frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; } } ``` -## Разработка - -### Установка зависимостей +**Шаг 5:** Перезагрузите Caddy/Nginx: ```bash -npm install +# Для Caddy +docker exec caddy reload --config /etc/caddy/Caddyfile + +# Для Nginx +docker exec nginx -s reload ``` -### Запуск dev сервера +#### B. Если Caddy/Nginx ещё НЕ запущен: -```bash -npm run dev +docker-compose.yml: +```yaml +services: + cabinet-frontend: + image: ghcr.io/bedolaga-dev/bedolaga-cabinet:latest + container_name: cabinet_frontend + restart: unless-stopped + # Можно открыть порт для прямого доступа (для тестирования) + # ports: + # - "3000:80" + networks: + - web + +networks: + web: + driver: bridge ``` -Откроется на `http://localhost:5173` - -### Сборка для production - -```bash -npm run build -``` - -Результат в папке `dist/` - -### Проверка типов - -```bash -npm run type-check -``` - -### Линтинг - -```bash -npm run lint -``` +Затем настройте Caddy/Nginx в той же сети `web`. ## Переменные окружения @@ -290,6 +341,30 @@ bedolaga-cabinet/ 2. Контейнеры находятся в одной Docker сети 3. Имя сервиса backend в прокси конфигурации правильное +**Проверка связности контейнеров:** + +```bash +# Проверить что frontend доступен из Caddy/Nginx контейнера +docker exec wget -qO- http://cabinet_frontend:80 + +# Проверить что backend доступен +docker exec wget -qO- http://backend_bot:8080/health + +# Проверить в какой сети находятся контейнеры +docker inspect cabinet_frontend | grep -A 10 Networks +docker inspect | grep -A 10 Networks +``` + +Если контейнеры в разных сетях, подключите их: + +```bash +# Подключить frontend к сети Caddy +docker network connect cabinet_frontend + +# ИЛИ подключить Caddy к сети frontend +docker network connect +``` + ### Telegram авторизация не работает 1. Проверьте `VITE_TELEGRAM_BOT_USERNAME` - должен быть без `@` diff --git a/docker-compose.yml b/docker-compose.yml index 9c0fe6b..2a5c551 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,7 @@ +# Docker Compose для локальной разработки/тестирования +# Для production используйте готовые образы или настройте Caddy/Nginx +# См. README.md раздел "Настройка прокси для production" + services: cabinet-frontend: build: @@ -11,10 +15,14 @@ services: VITE_APP_LOGO: ${VITE_APP_LOGO:-V} container_name: cabinet_frontend restart: unless-stopped + env_file: + - .env # Загрузить переменные из .env файла ports: + # Маппинг: <хост-порт>:<контейнер-порт> + # Внутри контейнера nginx слушает на порту 80 - "${CABINET_PORT:-3000}:80" healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/health"] + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"] interval: 30s timeout: 3s retries: 3 diff --git a/src/api/admin.ts b/src/api/admin.ts index 095103e..acc3d91 100644 --- a/src/api/admin.ts +++ b/src/api/admin.ts @@ -53,6 +53,22 @@ export interface AdminTicketStats { closed: number } +export interface TicketSettings { + sla_enabled: boolean + sla_minutes: number + sla_check_interval_seconds: number + sla_reminder_cooldown_minutes: number + support_system_mode: string // tickets, contact, both +} + +export interface TicketSettingsUpdate { + sla_enabled?: boolean + sla_minutes?: number + sla_check_interval_seconds?: number + sla_reminder_cooldown_minutes?: number + support_system_mode?: string +} + export interface AdminTicketListResponse { items: AdminTicket[] total: number @@ -108,6 +124,18 @@ export const adminApi = { const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/priority`, { priority }) return response.data }, + + // Get ticket settings + getTicketSettings: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/tickets/settings') + return response.data + }, + + // Update ticket settings + updateTicketSettings: async (settings: TicketSettingsUpdate): Promise => { + const response = await apiClient.patch('/cabinet/admin/tickets/settings', settings) + return response.data + }, } // ============ Dashboard Stats Types ============ @@ -169,12 +197,28 @@ export interface ServerStats { total_revenue_rubles: number } +export interface TariffStatItem { + tariff_id: number + tariff_name: string + active_subscriptions: number + trial_subscriptions: number + purchased_today: number + purchased_week: number + purchased_month: number +} + +export interface TariffStats { + tariffs: TariffStatItem[] + total_tariff_subscriptions: number +} + export interface DashboardStats { nodes: NodesOverview subscriptions: SubscriptionStats financial: FinancialStats servers: ServerStats revenue_chart: RevenueData[] + tariff_stats?: TariffStats } // ============ Dashboard Stats API ============ diff --git a/src/api/adminApps.ts b/src/api/adminApps.ts index 837628d..51a69af 100644 --- a/src/api/adminApps.ts +++ b/src/api/adminApps.ts @@ -5,6 +5,7 @@ export interface LocalizedText { ru: string zh?: string fa?: string + fr?: string } export interface AppButton { @@ -116,4 +117,330 @@ export const adminAppsApi = { }) return response.data }, + + // Get RemnaWave config status + getRemnaWaveStatus: async (): Promise<{ enabled: boolean; config_uuid: string | null }> => { + const response = await apiClient.get<{ enabled: boolean; config_uuid: string | null }>( + '/cabinet/admin/apps/remnawave/status' + ) + return response.data + }, + + // Set RemnaWave config UUID + setRemnaWaveUuid: async (uuid: string | null): Promise<{ enabled: boolean; config_uuid: string | null }> => { + const response = await apiClient.put<{ enabled: boolean; config_uuid: string | null }>( + '/cabinet/admin/apps/remnawave/uuid', + { uuid } + ) + return response.data + }, + + // List available RemnaWave configs + listRemnaWaveConfigs: async (): Promise<{ uuid: string; name: string; view_position: number }[]> => { + const response = await apiClient.get<{ uuid: string; name: string; view_position: number }[]>( + '/cabinet/admin/apps/remnawave/configs' + ) + return response.data + }, + + // Get RemnaWave subscription config + getRemnaWaveConfig: async (): Promise => { + 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 + svgLibrary?: Record + 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, + branding?: AppConfigBranding +): RemnawaveConfig => { + const platforms: Record = {} + + for (const [platform, apps] of Object.entries(platformApps)) { + platforms[platform] = { + apps: apps.map(cabinetAppToRemnawave), + } + } + + return { + platforms, + svgLibrary: { + download: { svgString: '' }, + plus: { svgString: '' }, + check: { svgString: '' }, + settings: { svgString: '' }, + }, + 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; branding?: AppConfigBranding } => { + const platformApps: Record = {} + + 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 } } diff --git a/src/api/balance.ts b/src/api/balance.ts index 93e60fb..b0a4d82 100644 --- a/src/api/balance.ts +++ b/src/api/balance.ts @@ -1,4 +1,4 @@ -import apiClient from './client' +import apiClient from './client' import type { Balance, Transaction, PaymentMethod, PaginatedResponse } from '../types' export const balanceApi = { @@ -27,7 +27,7 @@ export const balanceApi = { }, // Create top-up payment - createTopUp: async (amountKopeks: number, paymentMethod: string): Promise<{ + createTopUp: async (amountKopeks: number, paymentMethod: string, paymentOption?: string): Promise<{ payment_id: string payment_url: string amount_kopeks: number @@ -35,10 +35,18 @@ export const balanceApi = { status: string expires_at: string | null }> => { - const response = await apiClient.post('/cabinet/balance/topup', { + const payload: { + amount_kopeks: number + payment_method: string + payment_option?: string + } = { amount_kopeks: amountKopeks, payment_method: paymentMethod, - }) + } + if (paymentOption) { + payload.payment_option = paymentOption + } + const response = await apiClient.post('/cabinet/balance/topup', payload) return response.data }, diff --git a/src/api/tariffs.ts b/src/api/tariffs.ts index 26c1a09..e50ddbb 100644 --- a/src/api/tariffs.ts +++ b/src/api/tariffs.ts @@ -32,6 +32,8 @@ export interface TariffListItem { description: string | null is_active: boolean is_trial_available: boolean + is_daily: boolean + daily_price_kopeks: number traffic_limit_gb: number device_limit: number tier_level: number @@ -55,6 +57,7 @@ export interface TariffDetail { traffic_limit_gb: number device_limit: number device_price_kopeks: number | null + max_device_limit: number | null tier_level: number display_order: number period_prices: PeriodPrice[] @@ -91,6 +94,7 @@ export interface TariffCreateRequest { traffic_limit_gb?: number device_limit?: number device_price_kopeks?: number + max_device_limit?: number tier_level?: number period_prices?: PeriodPrice[] allowed_squads?: string[] @@ -122,6 +126,7 @@ export interface TariffUpdateRequest { traffic_limit_gb?: number device_limit?: number device_price_kopeks?: number + max_device_limit?: number tier_level?: number display_order?: number period_prices?: PeriodPrice[] diff --git a/src/components/TopUpModal.tsx b/src/components/TopUpModal.tsx index 501e7f4..d4716d0 100644 --- a/src/components/TopUpModal.tsx +++ b/src/components/TopUpModal.tsx @@ -53,8 +53,13 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) { const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency() const [amount, setAmount] = useState('') const [error, setError] = useState(null) + const [selectedOption, setSelectedOption] = useState( + method.options && method.options.length > 0 ? method.options[0].id : null + ) const popupRef = useRef(null) + const hasOptions = method.options && method.options.length > 0 + const minRubles = method.min_amount_kopeks / 100 const maxRubles = method.max_amount_kopeks / 100 @@ -121,7 +126,7 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) { status: string expires_at: string | null }, unknown, number>({ - mutationFn: (amountKopeks: number) => balanceApi.createTopUp(amountKopeks, method.id), + mutationFn: (amountKopeks: number) => balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined), onSuccess: (data) => { const redirectUrl = data.payment_url || (data as any).invoice_url if (redirectUrl) { @@ -141,6 +146,7 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); setError(null) + if (hasOptions && !selectedOption) { setError(t('balance.selectPaymentOption', 'Выберите способ оплаты')); return } const amountCurrency = parseFloat(amount) if (isNaN(amountCurrency) || amountCurrency <= 0) { setError(t('balance.invalidAmount', 'Invalid amount')); return } const amountRubles = convertToRub(amountCurrency) @@ -180,6 +186,33 @@ export default function TopUpModal({ method, onClose }: TopUpModalProps) {
+ {hasOptions && method.options && ( +
+ +
+ {method.options.map((option) => ( + + ))} +
+
+ )} +
{ if (mobileMenuOpen) { + // Scroll to top so header is visible + window.scrollTo({ top: 0, behavior: 'instant' }) document.body.style.overflow = 'hidden' } else { document.body.style.overflow = '' diff --git a/src/locales/en.json b/src/locales/en.json index a96ff17..3c1449a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -218,7 +218,9 @@ "newPackage": "New package", "switch": "Change", "switched": "Traffic changed" - } + }, + "purchasedTraffic": "Purchased Traffic", + "trafficResetAt": "Reset" }, "balance": { "title": "Balance", @@ -260,6 +262,8 @@ }, "minAmountError": "Minimum amount: {{amount}} ₽", "maxAmountError": "Maximum amount: {{amount}} ₽", + "paymentOption": "Payment option", + "selectPaymentOption": "Select payment option", "paymentMethods": { "yookassa": { "name": "YooKassa (Bank Card)", @@ -272,6 +276,34 @@ "telegram_stars": { "name": "Telegram Stars", "description": "Pay with Telegram Stars" + }, + "heleket": { + "name": "Heleket Crypto", + "description": "Pay with cryptocurrency via Heleket" + }, + "mulenpay": { + "name": "MulenPay", + "description": "Pay via MulenPay" + }, + "pal24": { + "name": "PAL24", + "description": "Pay via PAL24" + }, + "platega": { + "name": "Platega", + "description": "Pay via Platega" + }, + "wata": { + "name": "Wata", + "description": "Pay via Wata" + }, + "cloudpayments": { + "name": "CloudPayments", + "description": "Pay with bank card via CloudPayments" + }, + "freekassa": { + "name": "FreeKassa", + "description": "Pay via FreeKassa" } } }, @@ -301,8 +333,19 @@ "user": "User", "joined": "Joined", "hasPaid": "Has Paid", + "status": { + "pending": "Pending", + "paid": "Paid" + }, "earningsHistory": "Earnings History", - "reason": "Reason" + "reason": "Reason", + "reasons": { + "referral_registration_pending": "Referral Registration", + "referral_registration": "Referral Registration", + "referral_first_payment": "Referral First Payment", + "referral_subscription_renewal": "Referral Subscription Renewal", + "referral_bonus": "Referral Bonus" + } }, "support": { "title": "Support", @@ -514,7 +557,23 @@ "subscription": "Subscription", "connect": "Connect", "additional": "Additional" - } + }, + "import": "Import", + "export": "Export", + "importApps": "Import apps from JSON", + "exportApps": "Export apps to JSON", + "importPreview": "Import Preview", + "confirmImport": "Import Apps", + "importError": "Invalid JSON file format", + "importCreateError": "Failed to create some apps", + "noAppsToImport": "No apps to import", + "remnaWaveToggle": "Toggle RemnaWave source", + "refreshRemnaWave": "Refresh from RemnaWave", + "remnaWaveMode": "Showing apps from RemnaWave panel. Read-only mode.", + "remnaWaveSettings": "RemnaWave Settings", + "remnaWaveConfigUuid": "Config UUID", + "remnaWaveConfigUuidHint": "UUID of subscription page config from RemnaWave panel", + "availableConfigs": "Available configs" }, "tickets": { "title": "Ticket Management", @@ -534,7 +593,23 @@ "userLabel": "User", "adminLabel": "Admin", "replyPlaceholder": "Type your reply...", - "sendReply": "Send Reply" + "sendReply": "Send Reply", + "settings": "Settings", + "supportMode": "Support mode", + "supportModeDesc": "Choose how users can contact support", + "modeBoth": "Tickets and contact", + "modeTickets": "Tickets only", + "modeContact": "Contact only", + "slaSettings": "SLA Settings", + "slaEnabled": "Enable SLA (response time monitoring)", + "slaEnabledDesc": "Automatic notifications for overdue tickets", + "slaMinutes": "Response time (minutes)", + "slaMinutesDesc": "Maximum time to respond to a ticket (1-1440 minutes)", + "checkInterval": "Check interval (seconds)", + "checkIntervalDesc": "How often to check for overdue tickets (30-600 seconds)", + "reminderCooldown": "Reminder interval (minutes)", + "reminderCooldownDesc": "Minimum time between reminders (1-120 minutes)", + "settingsUpdateError": "Error saving settings" }, "tariffs": { "title": "Tariff Management", @@ -656,6 +731,17 @@ "available": "Available", "withConnections": "With Connections", "revenue": "Revenue" + }, + "tariffs": { + "title": "Tariff Statistics", + "subtitle": "Subscription distribution by tariffs", + "tariffName": "Tariff", + "activeSubscriptions": "Active", + "trialSubscriptions": "Trial", + "purchasedToday": "Today", + "purchasedWeek": "Week", + "purchasedMonth": "Month", + "noTariffs": "No tariffs" } }, "profile": { diff --git a/src/locales/fa.json b/src/locales/fa.json index 263a921..ebed06f 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1,711 +1,738 @@ -{ - "common": { - "loading": "در حال بارگذاری...", - "error": "خطا", - "success": "موفق", - "save": "ذخیره", - "cancel": "لغو", - "confirm": "تایید", - "back": "بازگشت", - "next": "بعدی", - "close": "بستن", - "search": "جستجو", - "noData": "داده‌ای موجود نیست", - "actions": "عملیات", - "yes": "بله", - "no": "خیر", - "or": "یا", - "and": "و", - "edit": "ویرایش", - "delete": "حذف", - "currency": "تومان" - }, - "nav": { - "dashboard": "داشبورد", - "subscription": "اشتراک", - "balance": "موجودی", - "referral": "معرفی", - "support": "پشتیبانی", - "profile": "پروفایل", - "logout": "خروج", - "contests": "مسابقات", - "polls": "نظرسنجی", - "info": "اطلاعات", - "wheel": "چرخ شانس" - }, - "auth": { - "login": "ورود", - "register": "ثبت نام", - "logout": "خروج", - "email": "ایمیل", - "password": "رمز عبور", - "confirmPassword": "تایید رمز عبور", - "forgotPassword": "رمز عبور را فراموش کردید؟", - "resetPassword": "بازنشانی رمز عبور", - "loginWithTelegram": "ورود با تلگرام", - "loginWithEmail": "ورود با ایمیل", - "noAccount": "حساب کاربری ندارید؟", - "hasAccount": "قبلاً حساب دارید؟", - "registerHint": "برای ثبت نام با ایمیل، ابتدا با تلگرام وارد شوید، سپس ایمیل خود را در تنظیمات متصل کنید.", - "telegramRequired": "نیاز به تایید تلگرام", - "telegramNotConfigured": "ربات تلگرام پیکربندی نشده", - "authenticating": "در حال تایید هویت...", - "orOpenInApp": "یا ربات را در برنامه باز کنید", - "loginFailed": "ورود ناموفق", - "tryAgain": "تلاش مجدد", - "welcomeBack": "خوش آمدید!", - "loginTitle": "ورود به کابین", - "loginSubtitle": "با تلگرام یا ایمیل وارد شوید" - }, - "emailVerification": { - "title": "تایید ایمیل", - "verifying": "در حال تایید ایمیل...", - "pleaseWait": "لطفاً صبر کنید تا ایمیل شما تایید شود.", - "success": "ایمیل تایید شد!", - "successMessage": "ایمیل شما با موفقیت تایید شد. اکنون می‌توانید با ایمیل و رمز عبور وارد شوید.", - "failed": "تایید ناموفق", - "goToLogin": "رفتن به صفحه ورود" - }, - "dashboard": { - "title": "داشبورد", - "welcome": "خوش آمدید، {{name}}!", - "yourSubscription": "اشتراک شما", - "quickActions": "دسترسی سریع", - "viewSubscription": "مدیریت اشتراک", - "topUpBalance": "شارژ موجودی", - "inviteFriends": "دعوت دوستان", - "getSupport": "دریافت پشتیبانی", - "currentBalance": "موجودی فعلی", - "activeUntil": "فعال تا", - "noActiveSubscription": "اشتراک فعال ندارید", - "devicesUsed": "دستگاه‌ها: {{used}} از {{total}}", - "trafficUsed": "ترافیک: {{used}} از {{total}} گیگ", - "unlimitedTraffic": "ترافیک نامحدود" - }, - "subscription": { - "title": "اشتراک", - "currentPlan": "پلن فعلی", - "status": "وضعیت", - "active": "فعال", - "inactive": "غیرفعال", - "trialStatus": "دوره آزمایشی", - "expired": "منقضی شده", - "expiresAt": "تاریخ انقضا", - "daysLeft": "روز باقی‌مانده", - "devices": "دستگاه‌ها", - "servers": "سرورها", - "traffic": "ترافیک", - "unlimited": "نامحدود", - "used": "استفاده شده", - "of": "از", - "renew": "تمدید", - "extend": "تمدید اشتراک", - "buyTraffic": "خرید ترافیک", - "buyDevices": "خرید دستگاه", - "renewalOptions": "گزینه‌های تمدید", - "days": "روز", - "hours": "ساعت", - "minutes": "دقیقه", - "price": "قیمت", - "noSubscription": "اشتراک فعال ندارید", - "getSubscription": "خرید اشتراک", - "connectionInfo": "اطلاعات اتصال", - "copyLink": "کپی لینک", - "copied": "کپی شد!", - "showQR": "نمایش QR کد", - "downloadConfig": "دانلود تنظیمات", - "trafficUsed": "ترافیک استفاده شده", - "timeLeft": "زمان باقی‌مانده", - "getConfig": "اتصال دستگاه‌ها", - "autoRenewal": "تمدید خودکار", - "daysBeforeExpiry": "روز قبل از انقضا", - "selectPeriod": "انتخاب دوره", - "selectTraffic": "انتخاب ترافیک", - "selectServers": "انتخاب سرورها", - "selectDevices": "انتخاب دستگاه‌ها", - "perDevice": "/ دستگاه", - "summary": "خلاصه", - "total": "جمع کل", - "purchase": "خرید", - "step": "مرحله {{current}} از {{total}}", - "stepPeriod": "دوره", - "stepTraffic": "ترافیک", - "stepServers": "سرورها", - "stepDevices": "دستگاه‌ها", - "stepConfirm": "تایید", - "trial": { - "title": "دوره آزمایشی رایگان", - "description": "سرویس VPN ما را رایگان امتحان کنید!", - "days": "روز", - "devices": "دستگاه", - "price": "هزینه فعال‌سازی", - "activate": "فعال‌سازی دوره آزمایشی", - "unavailable": "دوره آزمایشی در دسترس نیست" - }, - "connection": { - "title": "اتصال VPN", - "selectDevice": "دستگاه خود را انتخاب کنید", - "selectApp": "برنامه را انتخاب کنید", - "installApp": "۱. نصب برنامه", - "addSubscription": "۲. افزودن اشتراک", - "connectVpn": "۳. اتصال VPN", - "addToApp": "افزودن به {{appName}}", - "connect": "اتصال", - "noApps": "برنامه‌ای برای این پلتفرم موجود نیست", - "noSubscription": "برای اتصال نیاز به اشتراک فعال دارید", - "featured": "پیشنهادی", - "yourDevice": "دستگاه شما", - "copyLink": "کپی لینک اشتراک", - "copied": "لینک کپی شد!", - "openLink": "باز کردن لینک" - } - }, - "balance": { - "title": "موجودی", - "currentBalance": "موجودی فعلی", - "topUp": "شارژ", - "topUpBalance": "شارژ موجودی", - "paymentMethods": { - "yookassa": { - "name": "کارت بانکی", - "description": "پرداخت با کارت بانکی" - }, - "cryptobot": { - "name": "CryptoBot", - "description": "پرداخت با ارز دیجیتال" - }, - "telegram_stars": { - "name": "Telegram Stars", - "description": "پرداخت با Telegram Stars" - } - }, - "transactionHistory": "تاریخچه تراکنش‌ها", - "noTransactions": "تراکنشی موجود نیست", - "date": "تاریخ", - "type": "نوع", - "description": "توضیحات", - "amount": "مبلغ", - "deposit": "واریز", - "withdrawal": "برداشت", - "subscriptionPayment": "پرداخت اشتراک", - "referralReward": "پاداش معرفی", - "minAmount": "حداقل", - "maxAmount": "حداکثر", - "notAvailable": "در دسترس نیست", - "useBot": "لطفاً برای شارژ از ربات تلگرام استفاده کنید.", - "page": "صفحه {{current}} از {{total}}", - "invalidAmount": "مبلغ نامعتبر", - "promocode": { - "title": "کد تخفیف", - "placeholder": "کد تخفیف را وارد کنید", - "activate": "فعال‌سازی", - "activating": "در حال فعال‌سازی...", - "success": "کد تخفیف با موفقیت فعال شد!", - "balanceAdded": "موجودی اضافه شده: {{amount}}", - "errors": { - "not_found": "کد تخفیف یافت نشد", - "expired": "کد تخفیف منقضی شده", - "used": "کد تخفیف قبلاً استفاده شده", - "already_used_by_user": "شما قبلاً از این کد استفاده کرده‌اید", - "user_not_found": "کاربر یافت نشد", - "server_error": "خطای سرور" - } - }, - "minAmountError": "حداقل مبلغ: {{amount}}", - "maxAmountError": "حداکثر مبلغ: {{amount}}" - }, - "referral": { - "title": "برنامه معرفی", - "yourLink": "لینک معرفی شما", - "copyLink": "کپی لینک", - "copied": "کپی شد!", - "shareButton": "اشتراک‌گذاری", - "shareMessage": "از طریق لینک من به Cabinet بپیوند و تا {{percent}}٪ پاداش بگیر!", - "shareHint": "این لینک را با دوستان به اشتراک بگذارید. وقتی ثبت نام کنند و خرید کنند، {{percent}}% کمیسیون دریافت می‌کنید!", - "stats": { - "totalReferrals": "کل معرفی‌ها", - "activeReferrals": "فعال", - "totalEarnings": "کل درآمد", - "commissionRate": "نرخ کمیسیون" - }, - "terms": { - "title": "شرایط برنامه", - "commission": "کمیسیون", - "minTopup": "حداقل شارژ", - "newUserBonus": "جایزه کاربر جدید", - "inviterBonus": "جایزه معرف" - }, - "yourReferrals": "معرفی‌های شما", - "noReferrals": "هنوز معرفی ندارید. لینک را به اشتراک بگذارید!", - "user": "کاربر", - "joined": "تاریخ عضویت", - "hasPaid": "پرداخت کرده", - "earningsHistory": "تاریخچه درآمد", - "reason": "دلیل" - }, - "support": { - "title": "پشتیبانی", - "newTicket": "تیکت جدید", - "yourTickets": "تیکت‌های شما", - "noTickets": "تیکتی موجود نیست", - "createTicket": "ایجاد تیکت", - "subject": "موضوع", - "subjectPlaceholder": "مشکل را به طور خلاصه توضیح دهید", - "message": "پیام", - "messagePlaceholder": "مشکل خود را به طور کامل توضیح دهید...", - "send": "ارسال", - "sending": "در حال ارسال...", - "creating": "در حال ایجاد...", - "reply": "پاسخ", - "replyPlaceholder": "پاسخ خود را بنویسید...", - "sendReply": "ارسال پاسخ", - "you": "شما", - "supportTeam": "پشتیبانی", - "selectTicket": "یک تیکت انتخاب کنید یا جدید ایجاد کنید", - "created": "ایجاد شده", - "repliesDisabled": "پاسخ‌گویی به این تیکت غیرفعال است", - "status": { - "open": "باز", - "answered": "پاسخ داده شده", - "pending": "در انتظار", - "closed": "بسته" - }, - "attachImage": "پیوست تصویر", - "invalidFileType": "نوع فایل نامعتبر. از JPEG، PNG، GIF یا WebP استفاده کنید.", - "fileTooLarge": "فایل خیلی بزرگ است. حداکثر 10MB.", - "uploadFailed": "آپلود تصویر ناموفق", - "imageLoadFailed": "بارگذاری تصویر ناموفق" - }, - "wheel": { - "title": "چرخ شانس", - "disabled": "چرخ شانس فعلاً در دسترس نیست", - "spinsRemaining": "تعداد چرخش باقی‌مانده امروز", - "choosePayment": "روش پرداخت را انتخاب کنید", - "stars": "Stars", - "telegramStars": "Telegram Stars", - "days": "روز", - "subscriptionDays": "روز اشتراک", - "day": "روز", - "spin": "بچرخان!", - "spinning": "در حال چرخش...", - "history": "تاریخچه", - "recentSpins": "چرخش‌های اخیر", - "congratulations": "تبریک!", - "noLuck": "شانس نیاوردید!", - "oops": "اوه!", - "yourPromoCode": "کد تخفیف شما:", - "close": "بستن", - "errors": { - "networkError": "خطای شبکه رخ داد", - "loadFailed": "بارگذاری تنظیمات چرخ ناموفق", - "dailyLimitReached": "به محدودیت روزانه رسیدید", - "cannotSpin": "فعلاً امکان چرخش نیست", - "insufficientBalance": "موجودی کافی نیست", - "topUpRequired": "برای پرداخت شارژ کنید" - }, - "payWithStars": "پرداخت {stars} Stars", - "processingPayment": "در حال پردازش...", - "starsPaymentSuccess": "پرداخت موفق! نتیجه به تلگرام ارسال شد.", - "starsPaymentFailed": "پرداخت ناموفق. دوباره تلاش کنید.", - "noHistory": "هنوز تاریخچه‌ای نیست", - "banner": { - "title": "شانس خود را امتحان کنید!", - "description": "چرخ را بچرخانید و جایزه ببرید: روز اشتراک، موجودی، ترافیک و بیشتر!", - "button": "برو به چرخ" - } - }, - "admin": { - "nav": { - "title": "مدیریت", - "dashboard": "آمار", - "tickets": "تیکت‌ها", - "settings": "تنظیمات", - "apps": "برنامه‌ها", - "wheel": "چرخ", - "tariffs": "تعرفه‌ها", - "servers": "سرورها" - }, - "panel": { - "title": "پنل مدیریت", - "subtitle": "مدیریت سیستم", - "dashboardDesc": "آمار و مانیتورینگ سیستم", - "ticketsDesc": "مدیریت تیکت‌های کاربران", - "settingsDesc": "تنظیمات و پارامترهای سیستم", - "appsDesc": "مدیریت برنامه‌های اتصال", - "wheelDesc": "تنظیم چرخ شانس و جوایز", - "tariffsDesc": "مدیریت طرح‌های تعرفه", - "serversDesc": "تنظیم سرورهای VPN" - }, - "wheel": { - "title": "تنظیمات چرخ شانس", - "enabled": "فعال", - "disabled": "غیرفعال", - "tabs": { - "settings": "تنظیمات", - "prizes": "جوایز", - "statistics": "آمار" - }, - "settings": { - "enableWheel": "فعال‌سازی چرخ", - "allowSpins": "اجازه چرخش به کاربران", - "costInStars": "هزینه Stars", - "costInDays": "هزینه روز", - "rtpPercent": "درصد RTP", - "dailyLimit": "محدودیت روزانه (0 = نامحدود)", - "minSubDays": "حداقل روز اشتراک برای پرداخت روزانه", - "promoPrefix": "پیشوند کد تخفیف" - }, - "prizes": { - "addPrize": "افزودن جایزه", - "editPrize": "ویرایش جایزه", - "noPrizes": "جایزه‌ای تنظیم نشده. برای فعال‌سازی چرخ جایزه اضافه کنید.", - "deletePrize": "این جایزه حذف شود؟", - "types": { - "subscription_days": "روز اشتراک", - "balance_bonus": "جایزه موجودی", - "traffic_gb": "ترافیک GB", - "promocode": "کد تخفیف", - "nothing": "هیچی" - }, - "fields": { - "type": "نوع", - "displayName": "نام نمایشی", - "value": "مقدار", - "valueKopeks": "ارزش (کوپک، برای RTP)", - "emoji": "ایموجی", - "color": "رنگ", - "active": "فعال", - "worth": "ارزش" - }, - "promo": { - "title": "تنظیمات کد تخفیف", - "balanceBonus": "جایزه موجودی (کوپک)", - "subscriptionDays": "روز اشتراک" - } - }, - "statistics": { - "totalSpins": "کل چرخش‌ها", - "revenue": "درآمد", - "payouts": "پرداخت‌ها", - "actualRtp": "RTP واقعی", - "targetRtp": "هدف", - "prizeDistribution": "توزیع جوایز", - "times": "بار", - "topWins": "برترین برنده‌ها" - } - }, - "settings": { - "title": "تنظیمات سیستم", - "allCategories": "همه دسته‌ها", - "noSettings": "تنظیماتی یافت نشد", - "modified": "تغییر یافته", - "readOnly": "فقط خواندنی", - "reset": "بازنشانی", - "categoriesCount": "دسته", - "settingsCount": "تنظیم", - "expandAll": "باز کردن همه", - "collapseAll": "بستن همه", - "searchPlaceholder": "جستجوی تنظیمات...", - "searchCategoriesPlaceholder": "جستجوی دسته‌ها...", - "noSearchResults": "چیزی یافت نشد", - "example": "مثال" - }, - "apps": { - "title": "مدیریت برنامه‌ها", - "addApp": "افزودن برنامه", - "addFirstApp": "افزودن اولین برنامه", - "noApps": "برنامه‌ای برای این پلتفرم نیست", - "createApp": "ایجاد برنامه", - "editApp": "ویرایش برنامه", - "confirmDelete": "آیا مطمئن هستید که می‌خواهید این برنامه را حذف کنید؟", - "copyTo": "کپی به...", - "appId": "شناسه برنامه", - "appName": "نام", - "urlScheme": "URL Scheme", - "featured": "ویژه", - "base64Encoding": "کدگذاری Base64", - "buttons": "دکمه‌ها", - "button": "دکمه", - "addButton": "افزودن دکمه", - "buttonText": "متن دکمه", - "installDescription": "توضیحات نصب", - "subscriptionDescription": "توضیحات افزودن اشتراک", - "connectDescription": "توضیحات اتصال", - "beforeSubscription": "قبل از افزودن اشتراک", - "afterSubscription": "بعد از افزودن اشتراک", - "stepTitle": "عنوان", - "stepDescription": "توضیحات", - "tabs": { - "basic": "اصلی", - "installation": "نصب", - "subscription": "اشتراک", - "connect": "اتصال", - "additional": "اضافی" - } - }, - "tickets": { - "title": "مدیریت تیکت‌ها", - "total": "کل", - "list": "تیکت‌ها", - "noTickets": "تیکتی نیست", - "selectTicket": "یک تیکت از لیست انتخاب کنید", - "allStatuses": "همه وضعیت‌ها", - "statusOpen": "باز", - "statusPending": "در انتظار", - "statusAnswered": "پاسخ داده شده", - "statusClosed": "بسته", - "from": "از", - "created": "ایجاد شده", - "user": "کاربر", - "you": "شما", - "userLabel": "کاربر", - "adminLabel": "مدیر", - "replyPlaceholder": "پاسخ خود را بنویسید...", - "sendReply": "ارسال پاسخ" - }, - "tariffs": { - "title": "مدیریت تعرفه‌ها", - "subtitle": "پیکربندی پلن‌های تعرفه", - "create": "ایجاد تعرفه", - "edit": "ویرایش تعرفه", - "noTariffs": "تعرفه‌ای نیست", - "name": "نام", - "namePlaceholder": "نام تعرفه را وارد کنید", - "description": "توضیحات", - "descriptionPlaceholder": "توضیحات تعرفه را وارد کنید", - "trafficLimit": "محدودیت ترافیک", - "trafficHint": "0 = ترافیک نامحدود", - "deviceLimit": "محدودیت دستگاه", - "tierLevel": "سطح تعرفه", - "tierHint": "1-10، روی اولویت ارتقا تأثیر می‌گذارد", - "unlimited": "نامحدود", - "devices": "دستگاه", - "servers": "سرور", - "subscriptions": "اشتراک", - "trial": "آزمایشی", - "inactive": "غیرفعال", - "activate": "فعال‌سازی", - "deactivate": "غیرفعال‌سازی", - "toggleTrial": "تغییر آزمایشی", - "delete": "حذف", - "confirmDelete": "تعرفه حذف شود؟", - "confirmDeleteText": "این عمل قابل بازگشت نیست. تعرفه برای همیشه حذف می‌شود.", - "days": "روز", - "tabs": { - "basic": "اصلی", - "prices": "قیمت‌ها", - "servers": "سرورها" - }, - "pricesHint": "قیمت هر دوره را تنظیم کنید", - "serversHint": "سرورهای موجود برای این تعرفه را انتخاب کنید", - "noServers": "سرور موجود نیست" - }, - "servers": { - "title": "مدیریت سرورها", - "subtitle": "پیکربندی سرورهای VPN", - "sync": "همگام‌سازی", - "syncing": "در حال همگام‌سازی...", - "syncNow": "همگام‌سازی الان", - "noServers": "سروری نیست. با RemnaWave همگام‌سازی کنید.", - "edit": "ویرایش سرور", - "originalName": "نام اصلی", - "displayName": "نام نمایشی", - "displayNamePlaceholder": "نام نمایشی را وارد کنید", - "description": "توضیحات", - "descriptionPlaceholder": "توضیحات سرور را وارد کنید", - "countryCode": "کد کشور", - "price": "قیمت", - "priceHint": "هزینه اضافی سرور", - "maxUsers": "حداکثر کاربران", - "sortOrder": "ترتیب", - "unlimited": "نامحدود", - "stats": "آمار", - "currentUsers": "کاربران فعلی", - "activeSubscriptions": "اشتراک‌های فعال", - "usedByTariffs": "استفاده شده توسط تعرفه‌ها", - "trial": "آزمایشی", - "unavailable": "در دسترس نیست", - "full": "پر", - "enable": "فعال", - "disable": "غیرفعال", - "toggleTrial": "تغییر آزمایشی" - } - }, - "adminDashboard": { - "title": "پنل آمار", - "subtitle": "نمای کلی سیستم در لحظه", - "refresh": "بروزرسانی", - "loadError": "بارگیری آمار ناموفق بود", - "stats": { - "usersOnline": "کاربران آنلاین", - "activeSubscriptions": "اشتراک‌های فعال", - "incomeToday": "درآمد امروز", - "incomeMonth": "درآمد ماهانه", - "incomeTotal": "کل درآمد", - "subscriptionIncome": "درآمد اشتراک", - "total": "کل" - }, - "nodes": { - "title": "نودها", - "online": "آنلاین", - "offline": "آفلاین", - "disabled": "غیرفعال", - "noNodes": "نود موجود نیست", - "usersOnline": "آنلاین", - "traffic": "ترافیک", - "enable": "فعال", - "disable": "غیرفعال" - }, - "revenue": { - "title": "درآمد", - "last7Days": "۷ روز اخیر" - }, - "subscriptions": { - "title": "اشتراک‌ها", - "subtitle": "آمار اشتراک", - "active": "فعال", - "trial": "آزمایشی", - "paid": "پرداخت شده", - "expired": "منقضی شده", - "newSubscriptions": "اشتراک‌های جدید", - "today": "امروز", - "week": "هفته", - "month": "ماه", - "conversion": "تبدیل آزمایشی به پولی" - }, - "servers": { - "title": "سرورها", - "total": "کل سرورها", - "available": "در دسترس", - "withConnections": "با اتصال", - "revenue": "درآمد" - } - }, - "profile": { - "title": "پروفایل", - "accountInfo": "اطلاعات حساب", - "telegramId": "شناسه تلگرام", - "username": "نام کاربری", - "name": "نام", - "registeredAt": "تاریخ ثبت نام", - "emailAuth": "احراز هویت ایمیل", - "linkEmailDescription": "ایمیل خود را متصل کنید تا بدون تلگرام وارد شوید. پس از اتصال، ایمیل تایید دریافت خواهید کرد.", - "linkEmail": "اتصال ایمیل", - "emailRequired": "ایمیل الزامی است", - "passwordMinLength": "رمز عبور باید حداقل 8 کاراکتر باشد", - "passwordsMismatch": "رمزهای عبور مطابقت ندارند", - "passwordPlaceholder": "رمز عبور را وارد کنید", - "confirmPasswordPlaceholder": "رمز عبور را تایید کنید", - "passwordHint": "حداقل 8 کاراکتر", - "emailSent": "ایمیل تایید ارسال شد! صندوق خود را بررسی کنید.", - "verified": "تایید شده", - "notVerified": "تایید نشده", - "verificationRequired": "لطفاً ایمیل خود را تایید کنید تا از ورود با ایمیل استفاده کنید.", - "resendVerification": "ارسال مجدد ایمیل تایید", - "verificationResent": "ایمیل تایید مجدداً ارسال شد!", - "canLoginWithEmail": "اکنون می‌توانید با ایمیل و رمز عبور وارد شوید.", - "notifications": { - "title": "تنظیمات اعلان", - "subscriptionExpiry": "انقضای اشتراک", - "subscriptionExpiryDesc": "اعلان قبل از انقضای اشتراک", - "daysBeforeExpiry": "روز قبل از انقضا", - "trafficWarning": "هشدار ترافیک", - "trafficWarningDesc": "اعلان نزدیک شدن به محدودیت ترافیک", - "atPercent": "در درصد", - "balanceLow": "موجودی کم", - "balanceLowDesc": "اعلان موجودی کم", - "threshold": "آستانه", - "news": "اخبار", - "newsDesc": "دریافت اخبار و به‌روزرسانی‌ها", - "promoOffers": "پیشنهادات تبلیغاتی", - "promoOffersDesc": "دریافت پیشنهادات ویژه و تخفیف‌ها", - "unavailable": "تنظیمات اعلان در دسترس نیست" - } - }, - "theme": { - "colors": "رنگ‌های تم", - "reset": "بازنشانی", - "accent": "رنگ تأکیدی", - "accentDescription": "رنگ اصلی برند برای دکمه‌ها، لینک‌ها و عناصر تعاملی", - "darkTheme": "تم تیره", - "lightTheme": "تم روشن", - "background": "پس‌زمینه", - "surface": "سطح", - "text": "متن", - "textSecondary": "متن ثانویه", - "statusColors": "رنگ‌های وضعیت", - "success": "موفقیت", - "warning": "هشدار", - "error": "خطا", - "preview": "پیش‌نمایش", - "previewButton": "دکمه اصلی", - "previewSecondary": "دکمه ثانویه", - "light": "تم روشن", - "dark": "تم تیره" - }, - "languages": { - "ru": "Русский", - "en": "English", - "zh": "中文", - "fa": "فارسی" - }, - "contests": { - "title": "مسابقات", - "error": "بارگذاری مسابقات ناموفق", - "noContests": "مسابقه‌ای موجود نیست", - "days": "روز", - "play": "شرکت", - "alreadyPlayed": "قبلاً شرکت کردید", - "enterAnswer": "پاسخ خود را وارد کنید", - "submit": "ارسال", - "imHere": "من اینجام!" - }, - "polls": { - "title": "نظرسنجی‌ها", - "error": "بارگذاری نظرسنجی‌ها ناموفق", - "noPolls": "نظرسنجی موجود نیست", - "questions": "سوال", - "question": "سوال", - "of": "از", - "start": "شروع", - "continue": "ادامه", - "completed": "تکمیل شده", - "reward": "تومان" - }, - "info": { - "title": "اطلاعات", - "faq": "سوالات متداول", - "rules": "قوانین", - "privacy": "حریم خصوصی", - "offer": "شرایط خدمات", - "noFaq": "سوال متداولی موجود نیست", - "noContent": "محتوایی موجود نیست", - "updatedAt": "به‌روزرسانی" - }, - "onboarding": { - "skip": "رد کردن", - "finish": "پایان", - "steps": { - "welcome": { - "title": "خوش آمدید!", - "description": "این کابین شخصی شماست. اینجا می‌توانید اشتراک خود را مدیریت کنید، موجودی شارژ کنید و دستگاه‌ها را متصل کنید." - }, - "connectDevices": { - "title": "اتصال دستگاه‌ها", - "description": "این دکمه را بزنید تا راهنمای اتصال VPN روی دستگاه‌هایتان را دریافت کنید." - }, - "balance": { - "title": "موجودی شما", - "description": "موجودی فعلی شما اینجا نمایش داده می‌شود. برای شارژ کلیک کنید." - }, - "subscription": { - "title": "وضعیت اشتراک", - "description": "اینجا می‌توانید روزهای باقی‌مانده تا انقضای اشتراک را ببینید." - }, - "quickActions": { - "title": "دسترسی سریع", - "description": "از این دکمه‌ها برای دسترسی سریع به امکانات اصلی استفاده کنید: شارژ موجودی، تمدید اشتراک و دعوت دوستان." - } - } - } +{ + "common": { + "loading": "در حال بارگذاری...", + "error": "خطا", + "success": "موفق", + "save": "ذخیره", + "cancel": "لغو", + "confirm": "تایید", + "back": "بازگشت", + "next": "بعدی", + "close": "بستن", + "search": "جستجو", + "noData": "داده‌ای موجود نیست", + "actions": "عملیات", + "yes": "بله", + "no": "خیر", + "or": "یا", + "and": "و", + "edit": "ویرایش", + "delete": "حذف", + "currency": "تومان" + }, + "nav": { + "dashboard": "داشبورد", + "subscription": "اشتراک", + "balance": "موجودی", + "referral": "معرفی", + "support": "پشتیبانی", + "profile": "پروفایل", + "logout": "خروج", + "contests": "مسابقات", + "polls": "نظرسنجی", + "info": "اطلاعات", + "wheel": "چرخ شانس" + }, + "auth": { + "login": "ورود", + "register": "ثبت نام", + "logout": "خروج", + "email": "ایمیل", + "password": "رمز عبور", + "confirmPassword": "تایید رمز عبور", + "forgotPassword": "رمز عبور را فراموش کردید؟", + "resetPassword": "بازنشانی رمز عبور", + "loginWithTelegram": "ورود با تلگرام", + "loginWithEmail": "ورود با ایمیل", + "noAccount": "حساب کاربری ندارید؟", + "hasAccount": "قبلاً حساب دارید؟", + "registerHint": "برای ثبت نام با ایمیل، ابتدا با تلگرام وارد شوید، سپس ایمیل خود را در تنظیمات متصل کنید.", + "telegramRequired": "نیاز به تایید تلگرام", + "telegramNotConfigured": "ربات تلگرام پیکربندی نشده", + "authenticating": "در حال تایید هویت...", + "orOpenInApp": "یا ربات را در برنامه باز کنید", + "loginFailed": "ورود ناموفق", + "tryAgain": "تلاش مجدد", + "welcomeBack": "خوش آمدید!", + "loginTitle": "ورود به کابین", + "loginSubtitle": "با تلگرام یا ایمیل وارد شوید" + }, + "emailVerification": { + "title": "تایید ایمیل", + "verifying": "در حال تایید ایمیل...", + "pleaseWait": "لطفاً صبر کنید تا ایمیل شما تایید شود.", + "success": "ایمیل تایید شد!", + "successMessage": "ایمیل شما با موفقیت تایید شد. اکنون می‌توانید با ایمیل و رمز عبور وارد شوید.", + "failed": "تایید ناموفق", + "goToLogin": "رفتن به صفحه ورود" + }, + "dashboard": { + "title": "داشبورد", + "welcome": "خوش آمدید، {{name}}!", + "yourSubscription": "اشتراک شما", + "quickActions": "دسترسی سریع", + "viewSubscription": "مدیریت اشتراک", + "topUpBalance": "شارژ موجودی", + "inviteFriends": "دعوت دوستان", + "getSupport": "دریافت پشتیبانی", + "currentBalance": "موجودی فعلی", + "activeUntil": "فعال تا", + "noActiveSubscription": "اشتراک فعال ندارید", + "devicesUsed": "دستگاه‌ها: {{used}} از {{total}}", + "trafficUsed": "ترافیک: {{used}} از {{total}} گیگ", + "unlimitedTraffic": "ترافیک نامحدود" + }, + "subscription": { + "title": "اشتراک", + "currentPlan": "پلن فعلی", + "status": "وضعیت", + "active": "فعال", + "inactive": "غیرفعال", + "trialStatus": "دوره آزمایشی", + "expired": "منقضی شده", + "expiresAt": "تاریخ انقضا", + "daysLeft": "روز باقی‌مانده", + "devices": "دستگاه‌ها", + "servers": "سرورها", + "traffic": "ترافیک", + "unlimited": "نامحدود", + "used": "استفاده شده", + "of": "از", + "renew": "تمدید", + "extend": "تمدید اشتراک", + "buyTraffic": "خرید ترافیک", + "buyDevices": "خرید دستگاه", + "renewalOptions": "گزینه‌های تمدید", + "days": "روز", + "hours": "ساعت", + "minutes": "دقیقه", + "price": "قیمت", + "noSubscription": "اشتراک فعال ندارید", + "getSubscription": "خرید اشتراک", + "connectionInfo": "اطلاعات اتصال", + "copyLink": "کپی لینک", + "copied": "کپی شد!", + "showQR": "نمایش QR کد", + "downloadConfig": "دانلود تنظیمات", + "trafficUsed": "ترافیک استفاده شده", + "timeLeft": "زمان باقی‌مانده", + "getConfig": "اتصال دستگاه‌ها", + "autoRenewal": "تمدید خودکار", + "daysBeforeExpiry": "روز قبل از انقضا", + "selectPeriod": "انتخاب دوره", + "selectTraffic": "انتخاب ترافیک", + "selectServers": "انتخاب سرورها", + "selectDevices": "انتخاب دستگاه‌ها", + "perDevice": "/ دستگاه", + "summary": "خلاصه", + "total": "جمع کل", + "purchase": "خرید", + "step": "مرحله {{current}} از {{total}}", + "stepPeriod": "دوره", + "stepTraffic": "ترافیک", + "stepServers": "سرورها", + "stepDevices": "دستگاه‌ها", + "stepConfirm": "تایید", + "trial": { + "title": "دوره آزمایشی رایگان", + "description": "سرویس VPN ما را رایگان امتحان کنید!", + "days": "روز", + "devices": "دستگاه", + "price": "هزینه فعال‌سازی", + "activate": "فعال‌سازی دوره آزمایشی", + "unavailable": "دوره آزمایشی در دسترس نیست" + }, + "connection": { + "title": "اتصال VPN", + "selectDevice": "دستگاه خود را انتخاب کنید", + "selectApp": "برنامه را انتخاب کنید", + "installApp": "۱. نصب برنامه", + "addSubscription": "۲. افزودن اشتراک", + "connectVpn": "۳. اتصال VPN", + "addToApp": "افزودن به {{appName}}", + "connect": "اتصال", + "noApps": "برنامه‌ای برای این پلتفرم موجود نیست", + "noSubscription": "برای اتصال نیاز به اشتراک فعال دارید", + "featured": "پیشنهادی", + "yourDevice": "دستگاه شما", + "copyLink": "کپی لینک اشتراک", + "copied": "لینک کپی شد!", + "openLink": "باز کردن لینک" + } + }, + "balance": { + "title": "موجودی", + "currentBalance": "موجودی فعلی", + "topUp": "شارژ", + "topUpBalance": "شارژ موجودی", + "paymentMethods": { + "yookassa": { + "name": "کارت بانکی", + "description": "پرداخت با کارت بانکی" + }, + "cryptobot": { + "name": "CryptoBot", + "description": "پرداخت با ارز دیجیتال" + }, + "telegram_stars": { + "name": "Telegram Stars", + "description": "پرداخت با Telegram Stars" + } + }, + "transactionHistory": "تاریخچه تراکنش‌ها", + "noTransactions": "تراکنشی موجود نیست", + "date": "تاریخ", + "type": "نوع", + "description": "توضیحات", + "amount": "مبلغ", + "deposit": "واریز", + "withdrawal": "برداشت", + "subscriptionPayment": "پرداخت اشتراک", + "referralReward": "پاداش معرفی", + "minAmount": "حداقل", + "maxAmount": "حداکثر", + "notAvailable": "در دسترس نیست", + "useBot": "لطفاً برای شارژ از ربات تلگرام استفاده کنید.", + "page": "صفحه {{current}} از {{total}}", + "invalidAmount": "مبلغ نامعتبر", + "promocode": { + "title": "کد تخفیف", + "placeholder": "کد تخفیف را وارد کنید", + "activate": "فعال‌سازی", + "activating": "در حال فعال‌سازی...", + "success": "کد تخفیف با موفقیت فعال شد!", + "balanceAdded": "موجودی اضافه شده: {{amount}}", + "errors": { + "not_found": "کد تخفیف یافت نشد", + "expired": "کد تخفیف منقضی شده", + "used": "کد تخفیف قبلاً استفاده شده", + "already_used_by_user": "شما قبلاً از این کد استفاده کرده‌اید", + "user_not_found": "کاربر یافت نشد", + "server_error": "خطای سرور" + } + }, + "minAmountError": "حداقل مبلغ: {{amount}}", + "maxAmountError": "حداکثر مبلغ: {{amount}}" + }, + "referral": { + "title": "برنامه معرفی", + "yourLink": "لینک معرفی شما", + "copyLink": "کپی لینک", + "copied": "کپی شد!", + "shareButton": "اشتراک‌گذاری", + "shareMessage": "از طریق لینک من به Cabinet بپیوند و تا {{percent}}٪ پاداش بگیر!", + "shareHint": "این لینک را با دوستان به اشتراک بگذارید. وقتی ثبت نام کنند و خرید کنند، {{percent}}% کمیسیون دریافت می‌کنید!", + "stats": { + "totalReferrals": "کل معرفی‌ها", + "activeReferrals": "فعال", + "totalEarnings": "کل درآمد", + "commissionRate": "نرخ کمیسیون" + }, + "terms": { + "title": "شرایط برنامه", + "commission": "کمیسیون", + "minTopup": "حداقل شارژ", + "newUserBonus": "جایزه کاربر جدید", + "inviterBonus": "جایزه معرف" + }, + "yourReferrals": "معرفی‌های شما", + "noReferrals": "هنوز معرفی ندارید. لینک را به اشتراک بگذارید!", + "user": "کاربر", + "joined": "تاریخ عضویت", + "hasPaid": "پرداخت کرده", + "earningsHistory": "تاریخچه درآمد", + "reason": "دلیل", + "status": { + "pending": "در انتظار", + "paid": "پرداخت شده" + }, + "reasons": { + "referral_registration_pending": "ثبت‌نام کاربر معرفی‌شده", + "referral_registration": "ثبت‌نام کاربر معرفی‌شده", + "referral_first_payment": "اولین پرداخت کاربر معرفی‌شده", + "referral_subscription_renewal": "تمدید اشتراک کاربر معرفی‌شده", + "referral_bonus": "پاداش کاربر معرفی‌شده" + } + }, + "support": { + "title": "پشتیبانی", + "newTicket": "تیکت جدید", + "yourTickets": "تیکت‌های شما", + "noTickets": "تیکتی موجود نیست", + "createTicket": "ایجاد تیکت", + "subject": "موضوع", + "subjectPlaceholder": "مشکل را به طور خلاصه توضیح دهید", + "message": "پیام", + "messagePlaceholder": "مشکل خود را به طور کامل توضیح دهید...", + "send": "ارسال", + "sending": "در حال ارسال...", + "creating": "در حال ایجاد...", + "reply": "پاسخ", + "replyPlaceholder": "پاسخ خود را بنویسید...", + "sendReply": "ارسال پاسخ", + "you": "شما", + "supportTeam": "پشتیبانی", + "selectTicket": "یک تیکت انتخاب کنید یا جدید ایجاد کنید", + "created": "ایجاد شده", + "repliesDisabled": "پاسخ‌گویی به این تیکت غیرفعال است", + "status": { + "open": "باز", + "answered": "پاسخ داده شده", + "pending": "در انتظار", + "closed": "بسته" + }, + "attachImage": "پیوست تصویر", + "invalidFileType": "نوع فایل نامعتبر. از JPEG، PNG، GIF یا WebP استفاده کنید.", + "fileTooLarge": "فایل خیلی بزرگ است. حداکثر 10MB.", + "uploadFailed": "آپلود تصویر ناموفق", + "imageLoadFailed": "بارگذاری تصویر ناموفق" + }, + "wheel": { + "title": "چرخ شانس", + "disabled": "چرخ شانس فعلاً در دسترس نیست", + "spinsRemaining": "تعداد چرخش باقی‌مانده امروز", + "choosePayment": "روش پرداخت را انتخاب کنید", + "stars": "Stars", + "telegramStars": "Telegram Stars", + "days": "روز", + "subscriptionDays": "روز اشتراک", + "day": "روز", + "spin": "بچرخان!", + "spinning": "در حال چرخش...", + "history": "تاریخچه", + "recentSpins": "چرخش‌های اخیر", + "congratulations": "تبریک!", + "noLuck": "شانس نیاوردید!", + "oops": "اوه!", + "yourPromoCode": "کد تخفیف شما:", + "close": "بستن", + "errors": { + "networkError": "خطای شبکه رخ داد", + "loadFailed": "بارگذاری تنظیمات چرخ ناموفق", + "dailyLimitReached": "به محدودیت روزانه رسیدید", + "cannotSpin": "فعلاً امکان چرخش نیست", + "insufficientBalance": "موجودی کافی نیست", + "topUpRequired": "برای پرداخت شارژ کنید" + }, + "payWithStars": "پرداخت {stars} Stars", + "processingPayment": "در حال پردازش...", + "starsPaymentSuccess": "پرداخت موفق! نتیجه به تلگرام ارسال شد.", + "starsPaymentFailed": "پرداخت ناموفق. دوباره تلاش کنید.", + "noHistory": "هنوز تاریخچه‌ای نیست", + "banner": { + "title": "شانس خود را امتحان کنید!", + "description": "چرخ را بچرخانید و جایزه ببرید: روز اشتراک، موجودی، ترافیک و بیشتر!", + "button": "برو به چرخ" + } + }, + "admin": { + "nav": { + "title": "مدیریت", + "dashboard": "آمار", + "tickets": "تیکت‌ها", + "settings": "تنظیمات", + "apps": "برنامه‌ها", + "wheel": "چرخ", + "tariffs": "تعرفه‌ها", + "servers": "سرورها" + }, + "panel": { + "title": "پنل مدیریت", + "subtitle": "مدیریت سیستم", + "dashboardDesc": "آمار و مانیتورینگ سیستم", + "ticketsDesc": "مدیریت تیکت‌های کاربران", + "settingsDesc": "تنظیمات و پارامترهای سیستم", + "appsDesc": "مدیریت برنامه‌های اتصال", + "wheelDesc": "تنظیم چرخ شانس و جوایز", + "tariffsDesc": "مدیریت طرح‌های تعرفه", + "serversDesc": "تنظیم سرورهای VPN" + }, + "wheel": { + "title": "تنظیمات چرخ شانس", + "enabled": "فعال", + "disabled": "غیرفعال", + "tabs": { + "settings": "تنظیمات", + "prizes": "جوایز", + "statistics": "آمار" + }, + "settings": { + "enableWheel": "فعال‌سازی چرخ", + "allowSpins": "اجازه چرخش به کاربران", + "costInStars": "هزینه Stars", + "costInDays": "هزینه روز", + "rtpPercent": "درصد RTP", + "dailyLimit": "محدودیت روزانه (0 = نامحدود)", + "minSubDays": "حداقل روز اشتراک برای پرداخت روزانه", + "promoPrefix": "پیشوند کد تخفیف" + }, + "prizes": { + "addPrize": "افزودن جایزه", + "editPrize": "ویرایش جایزه", + "noPrizes": "جایزه‌ای تنظیم نشده. برای فعال‌سازی چرخ جایزه اضافه کنید.", + "deletePrize": "این جایزه حذف شود؟", + "types": { + "subscription_days": "روز اشتراک", + "balance_bonus": "جایزه موجودی", + "traffic_gb": "ترافیک GB", + "promocode": "کد تخفیف", + "nothing": "هیچی" + }, + "fields": { + "type": "نوع", + "displayName": "نام نمایشی", + "value": "مقدار", + "valueKopeks": "ارزش (کوپک، برای RTP)", + "emoji": "ایموجی", + "color": "رنگ", + "active": "فعال", + "worth": "ارزش" + }, + "promo": { + "title": "تنظیمات کد تخفیف", + "balanceBonus": "جایزه موجودی (کوپک)", + "subscriptionDays": "روز اشتراک" + } + }, + "statistics": { + "totalSpins": "کل چرخش‌ها", + "revenue": "درآمد", + "payouts": "پرداخت‌ها", + "actualRtp": "RTP واقعی", + "targetRtp": "هدف", + "prizeDistribution": "توزیع جوایز", + "times": "بار", + "topWins": "برترین برنده‌ها" + } + }, + "settings": { + "title": "تنظیمات سیستم", + "allCategories": "همه دسته‌ها", + "noSettings": "تنظیماتی یافت نشد", + "modified": "تغییر یافته", + "readOnly": "فقط خواندنی", + "reset": "بازنشانی", + "categoriesCount": "دسته", + "settingsCount": "تنظیم", + "expandAll": "باز کردن همه", + "collapseAll": "بستن همه", + "searchPlaceholder": "جستجوی تنظیمات...", + "searchCategoriesPlaceholder": "جستجوی دسته‌ها...", + "noSearchResults": "چیزی یافت نشد", + "example": "مثال" + }, + "apps": { + "title": "مدیریت برنامه‌ها", + "addApp": "افزودن برنامه", + "addFirstApp": "افزودن اولین برنامه", + "noApps": "برنامه‌ای برای این پلتفرم نیست", + "createApp": "ایجاد برنامه", + "editApp": "ویرایش برنامه", + "confirmDelete": "آیا مطمئن هستید که می‌خواهید این برنامه را حذف کنید؟", + "copyTo": "کپی به...", + "appId": "شناسه برنامه", + "appName": "نام", + "urlScheme": "URL Scheme", + "featured": "ویژه", + "base64Encoding": "کدگذاری Base64", + "buttons": "دکمه‌ها", + "button": "دکمه", + "addButton": "افزودن دکمه", + "buttonText": "متن دکمه", + "installDescription": "توضیحات نصب", + "subscriptionDescription": "توضیحات افزودن اشتراک", + "connectDescription": "توضیحات اتصال", + "beforeSubscription": "قبل از افزودن اشتراک", + "afterSubscription": "بعد از افزودن اشتراک", + "stepTitle": "عنوان", + "stepDescription": "توضیحات", + "tabs": { + "basic": "اصلی", + "installation": "نصب", + "subscription": "اشتراک", + "connect": "اتصال", + "additional": "اضافی" + } + }, + "tickets": { + "title": "مدیریت تیکت‌ها", + "total": "کل", + "list": "تیکت‌ها", + "noTickets": "تیکتی نیست", + "selectTicket": "یک تیکت از لیست انتخاب کنید", + "allStatuses": "همه وضعیت‌ها", + "statusOpen": "باز", + "statusPending": "در انتظار", + "statusAnswered": "پاسخ داده شده", + "statusClosed": "بسته", + "from": "از", + "created": "ایجاد شده", + "user": "کاربر", + "you": "شما", + "userLabel": "کاربر", + "adminLabel": "مدیر", + "replyPlaceholder": "پاسخ خود را بنویسید...", + "sendReply": "ارسال پاسخ", + "settings": "تنظیمات", + "supportMode": "حالت پشتیبانی", + "supportModeDesc": "انتخاب کنید کاربران چگونه می‌توانند با پشتیبانی تماس بگیرند", + "modeBoth": "تیکت و تماس", + "modeTickets": "فقط تیکت", + "modeContact": "فقط تماس", + "slaSettings": "تنظیمات SLA", + "slaEnabled": "فعال کردن SLA (نظارت بر زمان پاسخ)", + "slaEnabledDesc": "اعلان‌های خودکار برای تیکت‌های معوقه", + "slaMinutes": "زمان پاسخ (دقیقه)", + "slaMinutesDesc": "حداکثر زمان برای پاسخ به تیکت (1-1440 دقیقه)", + "checkInterval": "فاصله بررسی (ثانیه)", + "checkIntervalDesc": "چند وقت یکبار تیکت‌های معوقه را بررسی کنیم (30-600 ثانیه)", + "reminderCooldown": "فاصله یادآوری (دقیقه)", + "reminderCooldownDesc": "حداقل زمان بین یادآوری‌ها (1-120 دقیقه)", + "settingsUpdateError": "خطا در ذخیره تنظیمات" + }, + "tariffs": { + "title": "مدیریت تعرفه‌ها", + "subtitle": "پیکربندی پلن‌های تعرفه", + "create": "ایجاد تعرفه", + "edit": "ویرایش تعرفه", + "noTariffs": "تعرفه‌ای نیست", + "name": "نام", + "namePlaceholder": "نام تعرفه را وارد کنید", + "description": "توضیحات", + "descriptionPlaceholder": "توضیحات تعرفه را وارد کنید", + "trafficLimit": "محدودیت ترافیک", + "trafficHint": "0 = ترافیک نامحدود", + "deviceLimit": "محدودیت دستگاه", + "tierLevel": "سطح تعرفه", + "tierHint": "1-10، روی اولویت ارتقا تأثیر می‌گذارد", + "unlimited": "نامحدود", + "devices": "دستگاه", + "servers": "سرور", + "subscriptions": "اشتراک", + "trial": "آزمایشی", + "inactive": "غیرفعال", + "activate": "فعال‌سازی", + "deactivate": "غیرفعال‌سازی", + "toggleTrial": "تغییر آزمایشی", + "delete": "حذف", + "confirmDelete": "تعرفه حذف شود؟", + "confirmDeleteText": "این عمل قابل بازگشت نیست. تعرفه برای همیشه حذف می‌شود.", + "days": "روز", + "tabs": { + "basic": "اصلی", + "prices": "قیمت‌ها", + "servers": "سرورها" + }, + "pricesHint": "قیمت هر دوره را تنظیم کنید", + "serversHint": "سرورهای موجود برای این تعرفه را انتخاب کنید", + "noServers": "سرور موجود نیست" + }, + "servers": { + "title": "مدیریت سرورها", + "subtitle": "پیکربندی سرورهای VPN", + "sync": "همگام‌سازی", + "syncing": "در حال همگام‌سازی...", + "syncNow": "همگام‌سازی الان", + "noServers": "سروری نیست. با RemnaWave همگام‌سازی کنید.", + "edit": "ویرایش سرور", + "originalName": "نام اصلی", + "displayName": "نام نمایشی", + "displayNamePlaceholder": "نام نمایشی را وارد کنید", + "description": "توضیحات", + "descriptionPlaceholder": "توضیحات سرور را وارد کنید", + "countryCode": "کد کشور", + "price": "قیمت", + "priceHint": "هزینه اضافی سرور", + "maxUsers": "حداکثر کاربران", + "sortOrder": "ترتیب", + "unlimited": "نامحدود", + "stats": "آمار", + "currentUsers": "کاربران فعلی", + "activeSubscriptions": "اشتراک‌های فعال", + "usedByTariffs": "استفاده شده توسط تعرفه‌ها", + "trial": "آزمایشی", + "unavailable": "در دسترس نیست", + "full": "پر", + "enable": "فعال", + "disable": "غیرفعال", + "toggleTrial": "تغییر آزمایشی" + } + }, + "adminDashboard": { + "title": "پنل آمار", + "subtitle": "نمای کلی سیستم در لحظه", + "refresh": "بروزرسانی", + "loadError": "بارگیری آمار ناموفق بود", + "stats": { + "usersOnline": "کاربران آنلاین", + "activeSubscriptions": "اشتراک‌های فعال", + "incomeToday": "درآمد امروز", + "incomeMonth": "درآمد ماهانه", + "incomeTotal": "کل درآمد", + "subscriptionIncome": "درآمد اشتراک", + "total": "کل" + }, + "nodes": { + "title": "نودها", + "online": "آنلاین", + "offline": "آفلاین", + "disabled": "غیرفعال", + "noNodes": "نود موجود نیست", + "usersOnline": "آنلاین", + "traffic": "ترافیک", + "enable": "فعال", + "disable": "غیرفعال" + }, + "revenue": { + "title": "درآمد", + "last7Days": "۷ روز اخیر" + }, + "subscriptions": { + "title": "اشتراک‌ها", + "subtitle": "آمار اشتراک", + "active": "فعال", + "trial": "آزمایشی", + "paid": "پرداخت شده", + "expired": "منقضی شده", + "newSubscriptions": "اشتراک‌های جدید", + "today": "امروز", + "week": "هفته", + "month": "ماه", + "conversion": "تبدیل آزمایشی به پولی" + }, + "servers": { + "title": "سرورها", + "total": "کل سرورها", + "available": "در دسترس", + "withConnections": "با اتصال", + "revenue": "درآمد" + } + }, + "profile": { + "title": "پروفایل", + "accountInfo": "اطلاعات حساب", + "telegramId": "شناسه تلگرام", + "username": "نام کاربری", + "name": "نام", + "registeredAt": "تاریخ ثبت نام", + "emailAuth": "احراز هویت ایمیل", + "linkEmailDescription": "ایمیل خود را متصل کنید تا بدون تلگرام وارد شوید. پس از اتصال، ایمیل تایید دریافت خواهید کرد.", + "linkEmail": "اتصال ایمیل", + "emailRequired": "ایمیل الزامی است", + "passwordMinLength": "رمز عبور باید حداقل 8 کاراکتر باشد", + "passwordsMismatch": "رمزهای عبور مطابقت ندارند", + "passwordPlaceholder": "رمز عبور را وارد کنید", + "confirmPasswordPlaceholder": "رمز عبور را تایید کنید", + "passwordHint": "حداقل 8 کاراکتر", + "emailSent": "ایمیل تایید ارسال شد! صندوق خود را بررسی کنید.", + "verified": "تایید شده", + "notVerified": "تایید نشده", + "verificationRequired": "لطفاً ایمیل خود را تایید کنید تا از ورود با ایمیل استفاده کنید.", + "resendVerification": "ارسال مجدد ایمیل تایید", + "verificationResent": "ایمیل تایید مجدداً ارسال شد!", + "canLoginWithEmail": "اکنون می‌توانید با ایمیل و رمز عبور وارد شوید.", + "notifications": { + "title": "تنظیمات اعلان", + "subscriptionExpiry": "انقضای اشتراک", + "subscriptionExpiryDesc": "اعلان قبل از انقضای اشتراک", + "daysBeforeExpiry": "روز قبل از انقضا", + "trafficWarning": "هشدار ترافیک", + "trafficWarningDesc": "اعلان نزدیک شدن به محدودیت ترافیک", + "atPercent": "در درصد", + "balanceLow": "موجودی کم", + "balanceLowDesc": "اعلان موجودی کم", + "threshold": "آستانه", + "news": "اخبار", + "newsDesc": "دریافت اخبار و به‌روزرسانی‌ها", + "promoOffers": "پیشنهادات تبلیغاتی", + "promoOffersDesc": "دریافت پیشنهادات ویژه و تخفیف‌ها", + "unavailable": "تنظیمات اعلان در دسترس نیست" + } + }, + "theme": { + "colors": "رنگ‌های تم", + "reset": "بازنشانی", + "accent": "رنگ تأکیدی", + "accentDescription": "رنگ اصلی برند برای دکمه‌ها، لینک‌ها و عناصر تعاملی", + "darkTheme": "تم تیره", + "lightTheme": "تم روشن", + "background": "پس‌زمینه", + "surface": "سطح", + "text": "متن", + "textSecondary": "متن ثانویه", + "statusColors": "رنگ‌های وضعیت", + "success": "موفقیت", + "warning": "هشدار", + "error": "خطا", + "preview": "پیش‌نمایش", + "previewButton": "دکمه اصلی", + "previewSecondary": "دکمه ثانویه", + "light": "تم روشن", + "dark": "تم تیره" + }, + "languages": { + "ru": "Русский", + "en": "English", + "zh": "中文", + "fa": "فارسی" + }, + "contests": { + "title": "مسابقات", + "error": "بارگذاری مسابقات ناموفق", + "noContests": "مسابقه‌ای موجود نیست", + "days": "روز", + "play": "شرکت", + "alreadyPlayed": "قبلاً شرکت کردید", + "enterAnswer": "پاسخ خود را وارد کنید", + "submit": "ارسال", + "imHere": "من اینجام!" + }, + "polls": { + "title": "نظرسنجی‌ها", + "error": "بارگذاری نظرسنجی‌ها ناموفق", + "noPolls": "نظرسنجی موجود نیست", + "questions": "سوال", + "question": "سوال", + "of": "از", + "start": "شروع", + "continue": "ادامه", + "completed": "تکمیل شده", + "reward": "تومان" + }, + "info": { + "title": "اطلاعات", + "faq": "سوالات متداول", + "rules": "قوانین", + "privacy": "حریم خصوصی", + "offer": "شرایط خدمات", + "noFaq": "سوال متداولی موجود نیست", + "noContent": "محتوایی موجود نیست", + "updatedAt": "به‌روزرسانی" + }, + "onboarding": { + "skip": "رد کردن", + "finish": "پایان", + "steps": { + "welcome": { + "title": "خوش آمدید!", + "description": "این کابین شخصی شماست. اینجا می‌توانید اشتراک خود را مدیریت کنید، موجودی شارژ کنید و دستگاه‌ها را متصل کنید." + }, + "connectDevices": { + "title": "اتصال دستگاه‌ها", + "description": "این دکمه را بزنید تا راهنمای اتصال VPN روی دستگاه‌هایتان را دریافت کنید." + }, + "balance": { + "title": "موجودی شما", + "description": "موجودی فعلی شما اینجا نمایش داده می‌شود. برای شارژ کلیک کنید." + }, + "subscription": { + "title": "وضعیت اشتراک", + "description": "اینجا می‌توانید روزهای باقی‌مانده تا انقضای اشتراک را ببینید." + }, + "quickActions": { + "title": "دسترسی سریع", + "description": "از این دکمه‌ها برای دسترسی سریع به امکانات اصلی استفاده کنید: شارژ موجودی، تمدید اشتراک و دعوت دوستان." + } + } + } } \ No newline at end of file diff --git a/src/locales/ru.json b/src/locales/ru.json index 47deb36..c070a8b 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -218,7 +218,9 @@ "newPackage": "Новый пакет", "switch": "Изменить", "switched": "Трафик изменён" - } + }, + "purchasedTraffic": "Докупленный трафик", + "trafficResetAt": "Сброс" }, "balance": { "title": "Баланс", @@ -260,6 +262,8 @@ }, "minAmountError": "Минимальная сумма: {{amount}} ₽", "maxAmountError": "Максимальная сумма: {{amount}} ₽", + "paymentOption": "Способ оплаты", + "selectPaymentOption": "Выберите способ оплаты", "paymentMethods": { "yookassa": { "name": "ЮKassa (Банковская карта)", @@ -272,6 +276,34 @@ "telegram_stars": { "name": "Telegram Stars", "description": "Оплата через Telegram Stars" + }, + "heleket": { + "name": "Heleket Crypto", + "description": "Оплата криптовалютой через Heleket" + }, + "mulenpay": { + "name": "MulenPay", + "description": "Оплата через MulenPay" + }, + "pal24": { + "name": "PAL24", + "description": "Оплата через PAL24" + }, + "platega": { + "name": "Platega", + "description": "Оплата через Platega" + }, + "wata": { + "name": "Wata", + "description": "Оплата через Wata" + }, + "cloudpayments": { + "name": "CloudPayments", + "description": "Оплата банковской картой через CloudPayments" + }, + "freekassa": { + "name": "FreeKassa", + "description": "Оплата через FreeKassa" } } }, @@ -301,8 +333,19 @@ "user": "Пользователь", "joined": "Присоединился", "hasPaid": "Оплатил", + "status": { + "pending": "Ожидание", + "paid": "Оплачено" + }, "earningsHistory": "История начислений", - "reason": "Причина" + "reason": "Причина", + "reasons": { + "referral_registration_pending": "Регистрация реферала", + "referral_registration": "Регистрация реферала", + "referral_first_payment": "Первая оплата реферала", + "referral_subscription_renewal": "Продление подписки реферала", + "referral_bonus": "Бонус за реферала" + } }, "support": { "title": "Поддержка", @@ -514,7 +557,23 @@ "subscription": "Подписка", "connect": "Подключение", "additional": "Дополнительно" - } + }, + "import": "Импорт", + "export": "Экспорт", + "importApps": "Импорт приложений из JSON", + "exportApps": "Экспорт приложений в JSON", + "importPreview": "Предпросмотр импорта", + "confirmImport": "Импортировать", + "importError": "Неверный формат JSON файла", + "importCreateError": "Ошибка при создании приложений", + "noAppsToImport": "Нет приложений для импорта", + "remnaWaveToggle": "Переключить источник RemnaWave", + "refreshRemnaWave": "Обновить данные из RemnaWave", + "remnaWaveMode": "Отображаются приложения из панели RemnaWave. Режим только для чтения.", + "remnaWaveSettings": "Настройки RemnaWave", + "remnaWaveConfigUuid": "UUID конфигурации", + "remnaWaveConfigUuidHint": "UUID конфигурации страницы подписки из панели RemnaWave", + "availableConfigs": "Доступные конфигурации" }, "tickets": { "title": "Управление тикетами", @@ -534,7 +593,23 @@ "userLabel": "Пользователь", "adminLabel": "Админ", "replyPlaceholder": "Введите ваш ответ...", - "sendReply": "Отправить" + "sendReply": "Отправить", + "settings": "Настройки", + "supportMode": "Режим поддержки", + "supportModeDesc": "Выберите, как пользователи могут связаться с поддержкой", + "modeBoth": "Тикеты и контакт", + "modeTickets": "Только тикеты", + "modeContact": "Только контакт", + "slaSettings": "Настройки SLA", + "slaEnabled": "Включить SLA (мониторинг времени ответа)", + "slaEnabledDesc": "Автоматические уведомления о просроченных тикетах", + "slaMinutes": "Время ответа (минуты)", + "slaMinutesDesc": "Максимальное время для ответа на тикет (1-1440 минут)", + "checkInterval": "Интервал проверки (секунды)", + "checkIntervalDesc": "Как часто проверять просроченные тикеты (30-600 секунд)", + "reminderCooldown": "Интервал напоминаний (минуты)", + "reminderCooldownDesc": "Минимальное время между напоминаниями (1-120 минут)", + "settingsUpdateError": "Ошибка сохранения настроек" }, "tariffs": { "title": "Управление тарифами", @@ -656,6 +731,17 @@ "available": "Доступных", "withConnections": "С подключениями", "revenue": "Доход" + }, + "tariffs": { + "title": "Статистика по тарифам", + "subtitle": "Распределение подписок по тарифам", + "tariffName": "Тариф", + "activeSubscriptions": "Активных", + "trialSubscriptions": "Триал", + "purchasedToday": "Сегодня", + "purchasedWeek": "Неделя", + "purchasedMonth": "Месяц", + "noTariffs": "Нет тарифов" } }, "profile": { diff --git a/src/locales/zh.json b/src/locales/zh.json index e7fc726..730c70b 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1,4 +1,4 @@ -{ +{ "common": { "loading": "加载中...", "error": "错误", @@ -165,7 +165,20 @@ "currentBalance": "当前余额", "topUp": "充值", "topUpBalance": "充值余额", - "paymentMethods": "支付方式", + "paymentMethods": { + "yookassa": { + "name": "银行卡", + "description": "通过银行卡支付" + }, + "cryptobot": { + "name": "CryptoBot", + "description": "通过CryptoBot加密货币支付" + }, + "telegram_stars": { + "name": "Telegram Stars", + "description": "通过Telegram Stars支付" + } + }, "transactionHistory": "交易记录", "noTransactions": "暂无交易", "date": "日期", @@ -199,21 +212,7 @@ } }, "minAmountError": "最小金额:{{amount}}", - "maxAmountError": "最大金额:{{amount}}", - "paymentMethods": { - "yookassa": { - "name": "银行卡", - "description": "通过银行卡支付" - }, - "cryptobot": { - "name": "CryptoBot", - "description": "通过CryptoBot加密货币支付" - }, - "telegram_stars": { - "name": "Telegram Stars", - "description": "通过Telegram Stars支付" - } - } + "maxAmountError": "最大金额:{{amount}}" }, "referral": { "title": "推荐计划", @@ -242,7 +241,18 @@ "joined": "加入时间", "hasPaid": "已付款", "earningsHistory": "收益记录", - "reason": "原因" + "reason": "原因", + "status": { + "pending": "待定", + "paid": "已支付" + }, + "reasons": { + "referral_registration_pending": "推荐用户注册", + "referral_registration": "推荐用户注册", + "referral_first_payment": "推荐用户首次付款", + "referral_subscription_renewal": "推荐用户续订", + "referral_bonus": "推荐奖金" + } }, "support": { "title": "支持", @@ -462,7 +472,23 @@ "userLabel": "用户", "adminLabel": "管理员", "replyPlaceholder": "输入您的回复...", - "sendReply": "发送回复" + "sendReply": "发送回复", + "settings": "设置", + "supportMode": "支持模式", + "supportModeDesc": "选择用户如何联系支持", + "modeBoth": "工单和联系方式", + "modeTickets": "仅工单", + "modeContact": "仅联系方式", + "slaSettings": "SLA设置", + "slaEnabled": "启用 SLA(响应时间监控)", + "slaEnabledDesc": "自动通知过期工单", + "slaMinutes": "响应时间(分钟)", + "slaMinutesDesc": "回复工单的最长时间(1-1440分钟)", + "checkInterval": "检查间隔(秒)", + "checkIntervalDesc": "检查过期工单的频率(30-600秒)", + "reminderCooldown": "提醒间隔(分钟)", + "reminderCooldownDesc": "提醒之间的最小时间(1-120分钟)", + "settingsUpdateError": "保存设置时出错" }, "tariffs": { "title": "套餐管理", @@ -709,4 +735,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/pages/AdminApps.tsx b/src/pages/AdminApps.tsx index a84b54a..2ff0b12 100644 --- a/src/pages/AdminApps.tsx +++ b/src/pages/AdminApps.tsx @@ -1,7 +1,17 @@ -import { useState } from 'react' +import { useState, useRef } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' -import { adminAppsApi, AppDefinition, LocalizedText, AppStep, AppButton } from '../api/adminApps' +import { + adminAppsApi, + AppDefinition, + LocalizedText, + AppStep, + AppButton, + exportToRemnawaveFormat, + importFromRemnawaveFormat, + RemnawaveConfig, + importFromRemnawaveFormat as convertRemnawave, +} from '../api/adminApps' // Icons const AppsIcon = () => ( @@ -52,6 +62,37 @@ const StarIcon = ({ filled }: { filled: boolean }) => ( ) +const DownloadIcon = () => ( + + + +) + +const UploadIcon = () => ( + + + +) + +const CloudSyncIcon = () => ( + + + +) + +const RefreshIcon = () => ( + + + +) + +const SettingsIcon = () => ( + + + + +) + const PLATFORM_LABELS: Record = { ios: 'iOS', android: 'Android', @@ -389,12 +430,62 @@ export default function AdminApps() { const [selectedPlatform, setSelectedPlatform] = useState('ios') const [editingApp, setEditingApp] = useState<{ app: AppDefinition; isNew: boolean } | null>(null) const [copyTarget, setCopyTarget] = useState<{ appId: string; platform: string } | null>(null) + const [showImportModal, setShowImportModal] = useState(false) + const [importError, setImportError] = useState(null) + const [importPreview, setImportPreview] = useState<{ platformApps: Record } | null>(null) + const fileInputRef = useRef(null) + const [useRemnaWave, setUseRemnaWave] = useState(false) + const [showRemnaWaveSettings, setShowRemnaWaveSettings] = useState(false) + const [remnaWaveUuidInput, setRemnaWaveUuidInput] = useState('') - const { data: apps, isLoading } = useQuery({ + // Check if RemnaWave config is available + const { data: remnaWaveStatus, refetch: refetchStatus } = useQuery({ + queryKey: ['remnawave-status'], + queryFn: adminAppsApi.getRemnaWaveStatus, + staleTime: 60000, + }) + + // List available RemnaWave configs + const { data: availableConfigs, isLoading: isLoadingConfigs } = useQuery({ + queryKey: ['remnawave-configs-list'], + queryFn: adminAppsApi.listRemnaWaveConfigs, + enabled: showRemnaWaveSettings, + staleTime: 30000, + }) + + // Mutation for setting UUID + const setUuidMutation = useMutation({ + mutationFn: adminAppsApi.setRemnaWaveUuid, + onSuccess: () => { + refetchStatus() + queryClient.invalidateQueries({ queryKey: ['remnawave-config'] }) + setShowRemnaWaveSettings(false) + }, + }) + + // Fetch RemnaWave config when toggle is enabled + const { data: remnaWaveConfig, isLoading: isRemnaWaveLoading, refetch: refetchRemnaWave } = useQuery({ + queryKey: ['remnawave-config'], + queryFn: adminAppsApi.getRemnaWaveConfig, + enabled: useRemnaWave && remnaWaveStatus?.enabled, + staleTime: 0, + }) + + // Convert RemnaWave config to local format + const remnaWaveApps = remnaWaveConfig + ? convertRemnawave(remnaWaveConfig).platformApps[selectedPlatform] || [] + : [] + + const { data: localApps, isLoading: isLocalLoading } = useQuery({ queryKey: ['admin-apps', selectedPlatform], queryFn: () => adminAppsApi.getPlatformApps(selectedPlatform), + enabled: !useRemnaWave, }) + // Select which apps to show based on toggle + const apps = useRemnaWave ? remnaWaveApps : localApps + const isLoading = useRemnaWave ? isRemnaWaveLoading : isLocalLoading + const createMutation = useMutation({ mutationFn: ({ platform, app }: { platform: string; app: AppDefinition }) => adminAppsApi.createApp(platform, app), @@ -438,6 +529,85 @@ export default function AdminApps() { }, }) + // Fetch all platforms for export + const { data: allPlatformApps } = useQuery({ + queryKey: ['admin-apps-all'], + queryFn: async () => { + const result: Record = {} + 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) => { + const file = e.target.files?.[0] + if (!file) return + setImportError(null) + setImportPreview(null) + + const reader = new FileReader() + reader.onload = (event) => { + try { + const json = JSON.parse(event.target?.result as string) as RemnawaveConfig + const imported = importFromRemnawaveFormat(json) + setImportPreview(imported) + setShowImportModal(true) + } catch (err) { + setImportError(t('admin.apps.importError', 'Invalid JSON file')) + setShowImportModal(true) + } + } + reader.readAsText(file) + // Reset file input + if (fileInputRef.current) fileInputRef.current.value = '' + } + + // Confirm import handler + const handleConfirmImport = async () => { + if (!importPreview) return + try { + for (const [platform, apps] of Object.entries(importPreview.platformApps)) { + for (const app of apps) { + await adminAppsApi.createApp(platform, app) + } + } + queryClient.invalidateQueries({ queryKey: ['admin-apps'] }) + queryClient.invalidateQueries({ queryKey: ['admin-apps-all'] }) + setShowImportModal(false) + setImportPreview(null) + } catch (err) { + setImportError(t('admin.apps.importCreateError', 'Failed to create some apps')) + } + } + const handleMoveUp = (index: number) => { if (!apps || index === 0) return const newOrder = [...apps] @@ -468,11 +638,60 @@ export default function AdminApps() { return (
-
- -

{t('admin.apps.title')}

+
+
+ +

{t('admin.apps.title')}

+
+ + {/* RemnaWave Toggle & Settings */} +
+ {remnaWaveStatus?.enabled && ( + <> + + {useRemnaWave && ( + + )} + + )} + +
+ {/* RemnaWave Info Banner */} + {useRemnaWave && ( +
+ + {t('admin.apps.remnaWaveMode', 'Showing apps from RemnaWave panel. Changes here are read-only.')} +
+ )} + {/* Platform Tabs */}
{PLATFORMS.map((platform) => ( @@ -490,16 +709,44 @@ export default function AdminApps() { ))}
- {/* Add App Button */} -
- -
+ {/* Action Buttons - Only show when not in RemnaWave mode */} + {!useRemnaWave && ( +
+
+ + + +
+ +
+ )} {/* Apps List */} {isLoading ? ( @@ -513,23 +760,25 @@ export default function AdminApps() { key={app.id} className="card flex items-center gap-4 p-4" > - {/* Reorder buttons */} -
- - -
+ {/* Reorder buttons - Only show when not in RemnaWave mode */} + {!useRemnaWave && ( +
+ + +
+ )} {/* App Info */}
@@ -544,30 +793,32 @@ export default function AdminApps() {
{app.urlScheme}
- {/* Actions */} -
- - - -
+ {/* Actions - Only show when not in RemnaWave mode */} + {!useRemnaWave && ( +
+ + + +
+ )}
))}
@@ -624,6 +875,149 @@ export default function AdminApps() {
)} + + {/* Import Modal */} + {showImportModal && ( +
+
+

+ {t('admin.apps.importPreview', 'Import Preview')} +

+ + {importError && ( +
+ {importError} +
+ )} + + {importPreview && ( +
+ {Object.entries(importPreview.platformApps).map(([platform, platformApps]) => ( + platformApps.length > 0 && ( +
+

+ {PLATFORM_LABELS[platform] || platform} ({platformApps.length}) +

+
    + {platformApps.map((app, idx) => ( +
  • + {app.isFeatured && } + {app.name} + ({app.urlScheme || '-'}) +
  • + ))} +
+
+ ) + ))} + {Object.values(importPreview.platformApps).every(arr => arr.length === 0) && ( +
+ {t('admin.apps.noAppsToImport', 'No apps to import')} +
+ )} +
+ )} + +
+ + {importPreview && !importError && ( + + )} +
+
+
+ )} + + {/* RemnaWave Settings Modal */} + {showRemnaWaveSettings && ( +
+
+

+ + {t('admin.apps.remnaWaveSettings', 'RemnaWave Settings')} +

+ +
+
+ + setRemnaWaveUuidInput(e.target.value)} + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + className="input w-full font-mono text-sm" + /> +

+ {t('admin.apps.remnaWaveConfigUuidHint', 'UUID of subscription page config from RemnaWave panel')} +

+
+ + {/* Available configs from RemnaWave */} + {isLoadingConfigs ? ( +
+
+
+ ) : availableConfigs && availableConfigs.length > 0 && ( +
+ +
+ {availableConfigs.map((config) => ( + + ))} +
+
+ )} +
+ +
+ + +
+
+
+ )}
) } diff --git a/src/pages/AdminDashboard.tsx b/src/pages/AdminDashboard.tsx index 93240c8..97b0956 100644 --- a/src/pages/AdminDashboard.tsx +++ b/src/pages/AdminDashboard.tsx @@ -184,32 +184,32 @@ function RevenueChart({ data }: { data: { date: string; amount_rubles: number }[ ) } - const maxValue = Math.max(...data.map(d => d.amount_rubles), 1) const last7Days = data.slice(-7) + const maxValue = Math.max(...last7Days.map(d => d.amount_rubles), 1) return ( -
-
- {last7Days.map((item, index) => { - const height = (item.amount_rubles / maxValue) * 100 - const date = new Date(item.date) - const dayName = date.toLocaleDateString('ru-RU', { weekday: 'short' }) +
+ {last7Days.map((item, index) => { + const percentage = (item.amount_rubles / maxValue) * 100 + const date = new Date(item.date) + const dayName = date.toLocaleDateString('ru-RU', { weekday: 'short' }) + const dayNum = date.getDate() - return ( -
-
-
- {formatAmount(item.amount_rubles)} {currencySymbol} -
-
-
{dayName}
+ return ( +
+
+ {dayName}, {dayNum} + {formatAmount(item.amount_rubles)} {currencySymbol}
- ) - })} -
+
+
+
+
+ ) + })}
) } @@ -497,6 +497,58 @@ export default function AdminDashboard() {
)} + + {/* Tariff Stats */} + {stats?.tariff_stats && stats.tariff_stats.tariffs.length > 0 && ( +
+
+ +
+

{t('adminDashboard.tariffs.title')}

+

{t('adminDashboard.tariffs.subtitle')}

+
+
+ +
+ + + + + + + + + + + + + {stats.tariff_stats.tariffs.map((tariff) => ( + + + + + + + + + ))} + +
{t('adminDashboard.tariffs.tariffName')}{t('adminDashboard.tariffs.activeSubscriptions')}{t('adminDashboard.tariffs.trialSubscriptions')}{t('adminDashboard.tariffs.purchasedToday')}{t('adminDashboard.tariffs.purchasedWeek')}{t('adminDashboard.tariffs.purchasedMonth')}
+ {tariff.tariff_name} + + {tariff.active_subscriptions} + + {tariff.trial_subscriptions} + + {tariff.purchased_today} + + {tariff.purchased_week} + + {tariff.purchased_month} +
+
+
+ )}
) } diff --git a/src/pages/AdminTariffs.tsx b/src/pages/AdminTariffs.tsx index ed96bec..a83383b 100644 --- a/src/pages/AdminTariffs.tsx +++ b/src/pages/AdminTariffs.tsx @@ -55,10 +55,75 @@ const InfinityIcon = () => ( ) -// Default period options -const DEFAULT_PERIODS = [7, 14, 30, 90, 180, 365] +const CalendarIcon = () => ( + + + +) -interface TariffModalProps { +const SunIcon = () => ( + + + +) + +// Type selection modal +interface TariffTypeSelectProps { + onSelect: (isDaily: boolean) => void + onClose: () => void +} + +function TariffTypeSelect({ onSelect, onClose }: TariffTypeSelectProps) { + return ( +
+
+
+

Выберите тип тарифа

+ +
+
+ + +
+
+
+ ) +} + +// Period tariff modal +interface PeriodTariffModalProps { tariff?: TariffDetail | null servers: ServerInfo[] onSave: (data: TariffCreateRequest | TariffUpdateRequest) => void @@ -66,46 +131,37 @@ interface TariffModalProps { isLoading?: boolean } -function TariffModal({ tariff, servers, onSave, onClose, isLoading }: TariffModalProps) { - const { t } = useTranslation() +function PeriodTariffModal({ tariff, servers, onSave, onClose, isLoading }: PeriodTariffModalProps) { const isEdit = !!tariff const [name, setName] = useState(tariff?.name || '') const [description, setDescription] = useState(tariff?.description || '') - const [trafficLimitGb, setTrafficLimitGb] = useState(tariff?.traffic_limit_gb || 0) + const [trafficLimitGb, setTrafficLimitGb] = useState(tariff?.traffic_limit_gb || 100) const [deviceLimit, setDeviceLimit] = useState(tariff?.device_limit || 1) const [devicePriceKopeks, setDevicePriceKopeks] = useState(tariff?.device_price_kopeks || 0) + const [maxDeviceLimit, setMaxDeviceLimit] = useState(tariff?.max_device_limit || 0) const [tierLevel, setTierLevel] = useState(tariff?.tier_level || 1) const [periodPrices, setPeriodPrices] = useState( - tariff?.period_prices || DEFAULT_PERIODS.map(d => ({ days: d, price_kopeks: 0 })) + tariff?.period_prices?.length ? tariff.period_prices : [] ) const [selectedSquads, setSelectedSquads] = useState(tariff?.allowed_squads || []) const [serverTrafficLimits, setServerTrafficLimits] = useState>( tariff?.server_traffic_limits || {} ) - // Произвольное количество дней - const [customDaysEnabled, setCustomDaysEnabled] = useState(tariff?.custom_days_enabled || false) - const [pricePerDayKopeks, setPricePerDayKopeks] = useState(tariff?.price_per_day_kopeks || 0) - const [minDays, setMinDays] = useState(tariff?.min_days || 1) - const [maxDays, setMaxDays] = useState(tariff?.max_days || 365) - // Произвольный трафик - const [customTrafficEnabled, setCustomTrafficEnabled] = useState(tariff?.custom_traffic_enabled || false) - const [trafficPricePerGbKopeks, setTrafficPricePerGbKopeks] = useState(tariff?.traffic_price_per_gb_kopeks || 0) - const [minTrafficGb, setMinTrafficGb] = useState(tariff?.min_traffic_gb || 1) - const [maxTrafficGb, setMaxTrafficGb] = useState(tariff?.max_traffic_gb || 1000) // Докупка трафика const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(tariff?.traffic_topup_enabled || false) const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState(tariff?.max_topup_traffic_gb || 0) const [trafficTopupPackages, setTrafficTopupPackages] = useState>( tariff?.traffic_topup_packages || {} ) - // Дневной тариф - const [isDaily, setIsDaily] = useState(tariff?.is_daily || false) - const [dailyPriceKopeks, setDailyPriceKopeks] = useState(tariff?.daily_price_kopeks || 0) - const [activeTab, setActiveTab] = useState<'basic' | 'prices' | 'servers' | 'custom'>('basic') + + // Новый период для добавления + const [newPeriodDays, setNewPeriodDays] = useState(30) + const [newPeriodPrice, setNewPeriodPrice] = useState(300) + + const [activeTab, setActiveTab] = useState<'basic' | 'periods' | 'servers' | 'extra'>('basic') const handleSubmit = () => { - // Фильтруем лимиты только для выбранных серверов и только если они заданы (> 0) const filteredLimits: Record = {} for (const uuid of selectedSquads) { if (serverTrafficLimits[uuid] && serverTrafficLimits[uuid].traffic_limit_gb > 0) { @@ -119,27 +175,16 @@ function TariffModal({ tariff, servers, onSave, onClose, isLoading }: TariffModa traffic_limit_gb: trafficLimitGb, device_limit: deviceLimit, device_price_kopeks: devicePriceKopeks > 0 ? devicePriceKopeks : undefined, + max_device_limit: maxDeviceLimit > 0 ? maxDeviceLimit : undefined, tier_level: tierLevel, period_prices: periodPrices.filter(p => p.price_kopeks > 0), allowed_squads: selectedSquads, server_traffic_limits: Object.keys(filteredLimits).length > 0 ? filteredLimits : {}, - // Произвольное количество дней - custom_days_enabled: customDaysEnabled, - price_per_day_kopeks: pricePerDayKopeks, - min_days: minDays, - max_days: maxDays, - // Произвольный трафик - custom_traffic_enabled: customTrafficEnabled, - traffic_price_per_gb_kopeks: trafficPricePerGbKopeks, - min_traffic_gb: minTrafficGb, - max_traffic_gb: maxTrafficGb, - // Докупка трафика traffic_topup_enabled: trafficTopupEnabled, traffic_topup_packages: trafficTopupPackages, max_topup_traffic_gb: maxTopupTrafficGb, - // Дневной тариф - is_daily: isDaily, - daily_price_kopeks: dailyPriceKopeks, + is_daily: false, + daily_price_kopeks: 0, } onSave(data) } @@ -159,14 +204,26 @@ function TariffModal({ tariff, servers, onSave, onClose, isLoading }: TariffModa ) } - const updatePeriodPrice = (days: number, priceKopeks: number) => { - setPeriodPrices(prev => { - const existing = prev.find(p => p.days === days) - if (existing) { - return prev.map(p => p.days === days ? { ...p, price_kopeks: priceKopeks } : p) + const addPeriod = () => { + if (newPeriodDays > 0 && newPeriodPrice > 0) { + // Проверяем, нет ли уже такого периода + const exists = periodPrices.some(p => p.days === newPeriodDays) + if (!exists) { + setPeriodPrices(prev => [...prev, { days: newPeriodDays, price_kopeks: newPeriodPrice * 100 }].sort((a, b) => a.days - b.days)) + setNewPeriodDays(30) + setNewPeriodPrice(300) } - return [...prev, { days, price_kopeks: priceKopeks }] - }) + } + } + + const removePeriod = (days: number) => { + setPeriodPrices(prev => prev.filter(p => p.days !== days)) + } + + const updatePeriodPrice = (days: number, priceRubles: number) => { + setPeriodPrices(prev => prev.map(p => + p.days === days ? { ...p, price_kopeks: priceRubles * 100 } : p + )) } return ( @@ -174,9 +231,17 @@ function TariffModal({ tariff, servers, onSave, onClose, isLoading }: TariffModa
{/* Header */}
-

- {isEdit ? t('admin.tariffs.edit') : t('admin.tariffs.create')} -

+
+
+ +
+
+

+ {isEdit ? 'Редактирование тарифа' : 'Новый периодный тариф'} +

+

Оплата за период

+
+
@@ -184,7 +249,7 @@ function TariffModal({ tariff, servers, onSave, onClose, isLoading }: TariffModa {/* Tabs */}
- {(['basic', 'prices', 'servers', 'custom'] as const).map(tab => ( + {(['basic', 'periods', 'servers', 'extra'] as const).map(tab => ( ))}
@@ -208,31 +273,31 @@ function TariffModal({ tariff, servers, onSave, onClose, isLoading }: TariffModa
{/* Name */}
- + setName(e.target.value)} className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" - placeholder={t('admin.tariffs.namePlaceholder')} + placeholder="Например: Стандарт" />
{/* Description */}
- +