diff --git a/README.md b/README.md index d22bdc4..f5cb751 100644 --- a/README.md +++ b/README.md @@ -1,263 +1,153 @@ # Bedolaga Cabinet - Web Interface -Современный веб-интерфейс личного кабинета для VPN бота на базе [Remnawave Bedolaga Telegram Bot V3.0.0+](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot). +Веб-интерфейс личного кабинета для VPN бота на базе [Remnawave Bedolaga Telegram Bot V3.0.0+](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot). -## Возможности - -- 🔐 Авторизация через Telegram -- 💳 Пополнение баланса (YooKassa, CryptoBot, Stars и др.) -- 📊 Управление подписками и ключами -- 🎫 Система тикетов поддержки -- 🌐 Мультиязычность (EN/RU) -- 📱 Адаптивный дизайн -- 🎨 Настраиваемый брендинг -- Админпанель для управлени ботом -- Автоматическая передача subpage конфигов приложений из Remnawave -- ⚡ Fast - React + Vite + TypeScript +React + Vite + TypeScript | Авторизация через Telegram | Мультиязычность (EN/RU) | Адаптивный дизайн ## Требования -- Node.js 18+ (для разработки) -- Docker и Docker Compose (для production) +- Docker и Docker Compose - Запущенный backend бота с включенным Cabinet API +- Обратный прокси (Caddy / Nginx / Traefik) -## Быстрый старт +## Архитектура -### Вариант A: Готовый Docker образ - -```bash -docker pull ghcr.io/bedolaga-dev/bedolaga-cabinet:latest - -или - -docker pull bedolaga/bedolaga-cabinet:latest +``` +Браузер → Caddy/Nginx → /api/* → remnawave_bot:8080 (backend API) + → /* → /srv/cabinet (статика frontend) ``` -Затем настройте Caddy/Nginx для проксирования (см. раздел "Настройка прокси для production"). +Frontend — это статические файлы (HTML, JS, CSS). Обратный прокси выполняет две задачи: +1. Раздает статику frontend +2. Проксирует `/api/*` запросы на backend бота (с удалением префикса `/api`) -### Вариант B: Сборка из исходников +## Установка -#### 1. Клонирование репозитория +### Шаг 1. Настройка backend бота -```bash -git clone https://github.com/BEDOLAGA-DEV/bedolaga-cabinet.git -cd bedolaga-cabinet -``` - -#### 2. Настройка окружения - -**⚠️ ОБЯЗАТЕЛЬНО:** Скопируйте `.env.example` в `.env` перед запуском! - -```bash -cp .env.example .env -``` - -Docker Compose не запустится без `.env` файла. - -**Основные переменные:** - -```env -# API URL - путь к backend API -# Используйте /api если прокси на том же домене -# Или полный URL если backend на другом сервере -VITE_API_URL=/api - -# Telegram Bot Username (без @) -VITE_TELEGRAM_BOT_USERNAME=your_bot_username - -# Брендинг (опционально) -VITE_APP_NAME=My VPN Cabinet -VITE_APP_LOGO=V - -# Порт для Docker контейнера -CABINET_PORT=3020 -``` - -#### 3. Запуск в Docker - -```bash -docker compose up -d --build -``` - -Приложение будет доступно на `http://localhost:3020` - -## Настройка backend - -В `.env` файле вашего бота добавьте: +В `.env` файле бота добавьте: ```env # Включить Cabinet API CABINET_ENABLED=true -# JWT секрет для авторизации (сгенерируйте случайную строку) +# JWT секрет (сгенерируйте случайную строку: openssl rand -hex 32) CABINET_JWT_SECRET=your_random_secret_key_here -# Разрешенные origins для CORS -CABINET_ALLOWED_ORIGINS=http://localhost:3020,https://cabinet.yourdomain.com +# Разрешенные origins для CORS (домен, на котором будет кабинет) +CABINET_ALLOWED_ORIGINS=https://cabinet.example.com ``` -После изменений перезапустите бота. +Перезапустите бота после изменений. -## Настройка прокси для production +### Шаг 2. Получение frontend файлов -Frontend - это статические файлы (HTML, JS, CSS). Для работы нужно: - -1. Раздавать статику через веб-сервер -2. Проксировать `/api/*` запросы на backend бота - -> **💡 Важно:** Docker контейнер из этого репозитория содержит nginx, который слушает на **внутреннем порту 80**. -> Это НЕ хост-порт! Выберите один из вариантов ниже в зависимости от вашей инфраструктуры. - -### Вариант 1: Caddy раздает статику напрямую - -**✅ Рекомендуется** - без лишних слоев прокси, максимальная производительность. - -Соберите frontend и примонтируйте в Caddy: +#### Вариант A: Готовый Docker образ (рекомендуется) ```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 +docker pull ghcr.io/bedolaga-dev/bedolaga-cabinet:latest ``` -Caddyfile: +Извлеките собранные файлы из образа: + +```bash +# Создать временный контейнер и скопировать статику +docker create --name tmp_cabinet ghcr.io/bedolaga-dev/bedolaga-cabinet:latest +docker cp tmp_cabinet:/usr/share/nginx/html ./cabinet-dist +docker rm tmp_cabinet +``` + +#### Вариант B: Сборка из исходников + +```bash +git clone https://github.com/BEDOLAGA-DEV/bedolaga-cabinet.git +cd bedolaga-cabinet +cp .env.example .env +``` + +Отредактируйте `.env`: + +```env +VITE_API_URL=/api +VITE_TELEGRAM_BOT_USERNAME=your_bot_username +VITE_APP_NAME=My VPN +VITE_APP_LOGO=V +``` + +Соберите и извлеките: + +```bash +docker compose build +docker create --name tmp_cabinet cabinet_frontend +docker cp tmp_cabinet:/usr/share/nginx/html ./cabinet-dist +docker rm tmp_cabinet +``` + +### Шаг 3. Размещение файлов на сервере + +Скопируйте содержимое `cabinet-dist` в директорию, которую будет раздавать ваш прокси: + +```bash +# Создайте директорию на сервере +sudo mkdir -p /srv/cabinet + +# Скопируйте файлы (с локальной машины или напрямую на сервере) +sudo cp -r ./cabinet-dist/* /srv/cabinet/ +``` + +### Шаг 4. Настройка обратного прокси + +#### Caddy (рекомендуется) + +Caddy автоматически получает и обновляет SSL сертификаты. ```caddyfile -cabinet.yourdomain.com { - root * /srv/cabinet - encode gzip +https://cabinet.example.com { + encode gzip zstd - # API запросы на backend + # API запросы → backend бота (удаляет /api префикс) handle /api/* { uri strip_prefix /api reverse_proxy remnawave_bot:8080 } - @websockets { - header_regexp Connection *Upgrade* - header Upgrade websocket - } - # WebSocket соединения - handle /cabinet/ws { - uri strip_prefix /api - reverse_proxy remnawave_bot:8080 { - transport http { - read_timeout 0 - write_timeout 0 - } - } - } - - # Статические файлы + # Frontend статика handle { + root * /srv/cabinet try_files {path} /index.html file_server - } -} -``` + # Кэширование статических ассетов (JS, CSS, шрифты, изображения) + @static path *.js *.css *.woff *.woff2 *.ttf *.ico *.png *.jpg *.jpeg *.svg *.webp *.gif + header @static Cache-Control "public, max-age=31536000, immutable" -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 remnawave_bot:8080 - } - - # Frontend контейнер (nginx внутри на порту 80) - handle { - reverse_proxy cabinet_frontend:80 + # HTML без кэша (для обновлений SPA) + @html path *.html / + header @html Cache-Control "no-cache, must-revalidate" } } ``` -Nginx (добавьте в существующий конфиг): +> **Примечание:** `remnawave_bot:8080` — имя контейнера бота в Docker сети. +> Если Caddy запущен на хосте, а не в Docker, используйте `localhost:8080` или IP сервера. + +#### Nginx ```nginx server { listen 443 ssl http2; - server_name cabinet.yourdomain.com; + server_name cabinet.example.com; - ssl_certificate /path/to/cert.pem; + ssl_certificate /path/to/cert.pem; ssl_certificate_key /path/to/key.pem; - # API на backend + root /srv/cabinet; + index index.html; + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml image/svg+xml; + + # API запросы → backend бота location /api/ { rewrite ^/api/(.*) /$1 break; proxy_pass http://remnawave_bot:8080; @@ -268,28 +158,38 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } - # Frontend контейнер (nginx внутри на порту 80) + # Кэширование статических ассетов + location ~* \.(?:js|css|woff2?|ttf|ico|png|jpe?g|svg|webp|gif)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; + } + + # SPA fallback location / { - proxy_pass http://cabinet_frontend:80; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; + try_files $uri /index.html; + add_header Cache-Control "no-cache, must-revalidate"; } } ``` -**Шаг 5:** Перезагрузите Caddy/Nginx: +### Шаг 5. Запуск и проверка ```bash -# Для Caddy +# Перезагрузите Caddy docker exec caddy reload --config /etc/caddy/Caddyfile -# Для Nginx +# Или Nginx docker exec nginx -s reload ``` -#### B. Если Caddy/Nginx ещё НЕ запущен: +Откройте `https://cabinet.example.com` в браузере. -docker-compose.yml: +## Альтернативная установка: Docker контейнер с nginx внутри + +Если вы не хотите раздавать статику напрямую, можно проксировать запросы на Docker контейнер cabinet. + +### docker-compose.yml ```yaml services: @@ -297,106 +197,136 @@ services: image: ghcr.io/bedolaga-dev/bedolaga-cabinet:latest container_name: cabinet_frontend restart: unless-stopped - # Можно открыть порт для прямого доступа (для тестирования) - # ports: - # - "3020:80" + # Порты НЕ пробрасываем — доступ только через Docker сеть networks: - - web + - bot_network networks: - web: - driver: bridge + bot_network: + external: true + name: remnawave-bedolaga-telegram-bot_bot_network # Сеть вашего бота ``` -Затем настройте Caddy/Nginx в той же сети `web`. +### Caddyfile + +```caddyfile +https://cabinet.example.com { + encode gzip zstd + + # API запросы → backend бота + handle /api/* { + uri strip_prefix /api + reverse_proxy remnawave_bot:8080 + } + + # Frontend → nginx контейнер (порт 80 внутри Docker сети) + handle { + reverse_proxy cabinet_frontend:80 + } +} +``` + +> В этом варианте кэшированием статики занимается nginx внутри контейнера. ## Переменные окружения -### Build-time (используются при сборке) +### Build-time (вшиваются в JS при сборке) -| Переменная | Описание | По умолчанию | -| ---------------------------- | ---------------------------------- | ------------ | -| `VITE_API_URL` | Путь к API (`/api` или полный URL) | `/api` | -| `VITE_TELEGRAM_BOT_USERNAME` | Username Telegram бота (без @) | - | -| `VITE_APP_NAME` | Название приложения | `Cabinet` | -| `VITE_APP_LOGO` | Логотип (короткий текст) | `V` | +| Переменная | Описание | По умолчанию | +|---|---|---| +| `VITE_API_URL` | Путь к API (`/api` или полный URL) | `/api` | +| `VITE_TELEGRAM_BOT_USERNAME` | Username Telegram бота (без @) | — | +| `VITE_APP_NAME` | Название в шапке и вкладке браузера | `Cabinet` | +| `VITE_APP_LOGO` | Текст логотипа (1-2 символа) | `V` | -### Runtime (только для Docker) +### Runtime (только для Docker контейнера) -| Переменная | Описание | По умолчанию | -| -------------- | --------------- | ------------ | -| `CABINET_PORT` | Порт контейнера | `3020` | +| Переменная | Описание | По умолчанию | +|---|---|---| +| `CABINET_PORT` | Порт контейнера на хосте | `3020` | + +### Backend бота (.env бота) + +| Переменная | Описание | По умолчанию | +|---|---|---| +| `CABINET_ENABLED` | Включить Cabinet API | `false` | +| `CABINET_JWT_SECRET` | Секретный ключ для JWT | `BOT_TOKEN` | +| `CABINET_ALLOWED_ORIGINS` | CORS origins (через запятую) | — | +| `CABINET_ACCESS_TOKEN_EXPIRE_MINUTES` | Время жизни access token | `15` | +| `CABINET_REFRESH_TOKEN_EXPIRE_DAYS` | Время жизни refresh token | `7` | + +## Устранение проблем + +### CORS ошибка в консоли браузера + +Домен кабинета не добавлен в `CABINET_ALLOWED_ORIGINS` в `.env` бота. Добавьте и перезапустите бота. + +### API возвращает HTML вместо JSON + +Прокси настроен неправильно — запросы `/api/*` попадают на frontend вместо backend. Проверьте порядок блоков `handle` в Caddyfile (API должен быть первым). + +### 502 Bad Gateway + +1. Backend бота не запущен — проверьте `docker ps` +2. Контейнеры в разных Docker сетях — проверьте и подключите: + ```bash + # Проверить сети контейнера + docker inspect --format='{{json .NetworkSettings.Networks}}' | python3 -m json.tool + + # Подключить к нужной сети + docker network connect + ``` +3. Неправильное имя сервиса в прокси — проверьте через: + ```bash + docker exec wget -qO- http://remnawave_bot:8080/health + ``` + +### Telegram авторизация не работает + +1. `VITE_TELEGRAM_BOT_USERNAME` должен быть без `@` +2. Домен кабинета добавлен в BotFather → Bot Settings → Domain + +### Белый экран / SPA не работает + +Прокси не настроен на fallback к `index.html`. Убедитесь что `try_files {path} /index.html` (Caddy) или `try_files $uri /index.html` (Nginx) присутствует в конфигурации. + +## Разработка + +```bash +git clone https://github.com/BEDOLAGA-DEV/bedolaga-cabinet.git +cd bedolaga-cabinet +npm install +cp .env.example .env +# Отредактируйте .env +npm run dev +``` + +Dev-сервер запустится на `http://localhost:5173` с автоматическим проксированием `/api` на `localhost:8080`. ## Структура проекта ``` bedolaga-cabinet/ ├── src/ -│ ├── api/ # API клиенты -│ ├── components/ # React компоненты -│ ├── contexts/ # React контексты +│ ├── api/ # API клиенты (axios) +│ ├── components/ # React компоненты (UI kit) +│ ├── contexts/ # React контексты (auth, theme) │ ├── hooks/ # Custom hooks │ ├── locales/ # Переводы (i18n) │ ├── pages/ # Страницы приложения │ ├── types/ # TypeScript типы │ └── utils/ # Утилиты ├── public/ # Статические файлы -├── Dockerfile # Docker образ -├── docker-compose.yml # Docker Compose конфигурация +├── Dockerfile # Multi-stage сборка (node → nginx) +├── docker-compose.yml # Docker Compose для сборки +├── nginx.conf # Nginx конфиг внутри контейнера └── .env.example # Пример переменных окружения ``` -## Устранение проблем - -### Ошибка CORS - -Убедитесь, что домен frontend добавлен в `CABINET_ALLOWED_ORIGINS` в настройках бота. - -### API возвращает HTML вместо JSON - -Проверьте настройку прокси - запросы на `/api/*` должны попадать на backend, а не на frontend. - -### 502 Bad Gateway - -Убедитесь что: - -1. Backend бот запущен и работает -2. Контейнеры находятся в одной Docker сети -3. Имя сервиса backend в прокси конфигурации правильное - -**Проверка связности контейнеров:** - -```bash -# Проверить что frontend доступен из Caddy/Nginx контейнера -docker exec wget -qO- http://cabinet_frontend:80 - -# Проверить что backend доступен -docker exec wget -qO- http://remnawave_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` - должен быть без `@` -2. Убедитесь что домен добавлен в Bot Settings → Domain - ## Связанные проекты -- [Remnawave Bedolaga Telegram Bot](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot) - Backend бота -- [Bedolaga Chat](https://t.me/+wTdMtSWq8YdmZmVi) - Чат поддержки +- [Remnawave Bedolaga Telegram Bot](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot) — Backend бота +- [Bedolaga Chat](https://t.me/+wTdMtSWq8YdmZmVi) — Чат поддержки ## Контакты diff --git a/src/api/buttonStyles.ts b/src/api/buttonStyles.ts new file mode 100644 index 0000000..c1ee6e2 --- /dev/null +++ b/src/api/buttonStyles.ts @@ -0,0 +1,92 @@ +import apiClient from './client'; + +export interface ButtonSectionConfig { + style: 'primary' | 'success' | 'danger' | 'default'; + icon_custom_emoji_id: string; + enabled: boolean; + labels: Record; +} + +export interface ButtonStylesConfig { + home: ButtonSectionConfig; + subscription: ButtonSectionConfig; + balance: ButtonSectionConfig; + referral: ButtonSectionConfig; + support: ButtonSectionConfig; + info: ButtonSectionConfig; + admin: ButtonSectionConfig; +} + +export type ButtonStylesUpdate = { + [K in keyof ButtonStylesConfig]?: Partial; +}; + +export const BUTTON_SECTIONS = [ + 'home', + 'subscription', + 'balance', + 'referral', + 'support', + 'info', + 'admin', +] as const; + +export type ButtonSection = (typeof BUTTON_SECTIONS)[number]; + +// Bot-side locales (includes 'ua' for Ukrainian, mapped from ISO 'uk' internally). +export const BOT_LOCALES = ['ru', 'en', 'ua', 'zh', 'fa'] as const; + +export type BotLocale = (typeof BOT_LOCALES)[number]; + +const DEFAULT_SECTION: ButtonSectionConfig = { + style: 'primary', + icon_custom_emoji_id: '', + enabled: true, + labels: {}, +}; + +export const DEFAULT_BUTTON_STYLES: ButtonStylesConfig = { + home: { ...DEFAULT_SECTION, style: 'primary' }, + subscription: { ...DEFAULT_SECTION, style: 'success' }, + balance: { ...DEFAULT_SECTION, style: 'primary' }, + referral: { ...DEFAULT_SECTION, style: 'success' }, + support: { ...DEFAULT_SECTION, style: 'primary' }, + info: { ...DEFAULT_SECTION, style: 'primary' }, + admin: { ...DEFAULT_SECTION, style: 'danger' }, +}; + +function normalizeConfig(data: ButtonStylesConfig): ButtonStylesConfig { + const result = {} as ButtonStylesConfig; + for (const section of BUTTON_SECTIONS) { + result[section] = { + ...DEFAULT_SECTION, + ...data[section], + labels: { ...(data[section]?.labels || {}) }, + }; + } + return result; +} + +export const buttonStylesApi = { + getStyles: async (): Promise => { + try { + const response = await apiClient.get('/cabinet/admin/button-styles'); + return normalizeConfig(response.data); + } catch { + return DEFAULT_BUTTON_STYLES; + } + }, + + updateStyles: async (update: ButtonStylesUpdate): Promise => { + const response = await apiClient.patch( + '/cabinet/admin/button-styles', + update, + ); + return normalizeConfig(response.data); + }, + + resetStyles: async (): Promise => { + const response = await apiClient.post('/cabinet/admin/button-styles/reset'); + return normalizeConfig(response.data); + }, +}; diff --git a/src/components/admin/ButtonsTab.tsx b/src/components/admin/ButtonsTab.tsx new file mode 100644 index 0000000..6e300ea --- /dev/null +++ b/src/components/admin/ButtonsTab.tsx @@ -0,0 +1,357 @@ +import { useEffect, useState, useRef, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + buttonStylesApi, + ButtonStylesConfig, + DEFAULT_BUTTON_STYLES, + BUTTON_SECTIONS, + ButtonSection, + BOT_LOCALES, +} from '../../api/buttonStyles'; +import { Toggle } from './Toggle'; +import { useNotify } from '../../platform/hooks/useNotify'; + +type StyleValue = 'primary' | 'success' | 'danger' | 'default'; + +const STYLE_OPTIONS: { value: StyleValue; colorClass: string }[] = [ + { value: 'default', colorClass: 'bg-dark-500' }, + { value: 'primary', colorClass: 'bg-blue-500' }, + { value: 'success', colorClass: 'bg-green-500' }, + { value: 'danger', colorClass: 'bg-red-500' }, +]; + +function labelsEqual(a: Record, b: Record): boolean { + for (const locale of BOT_LOCALES) { + if ((a[locale] || '') !== (b[locale] || '')) return false; + } + return true; +} + +function stylesEqual(a: ButtonStylesConfig, b: ButtonStylesConfig): boolean { + for (const section of BUTTON_SECTIONS) { + if (a[section].style !== b[section].style) return false; + if (a[section].icon_custom_emoji_id !== b[section].icon_custom_emoji_id) return false; + if (a[section].enabled !== b[section].enabled) return false; + if (!labelsEqual(a[section].labels, b[section].labels)) return false; + } + return true; +} + +export function ButtonsTab() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const notify = useNotify(); + + const { data: serverStyles } = useQuery({ + queryKey: ['button-styles'], + queryFn: buttonStylesApi.getStyles, + }); + + const [draftStyles, setDraftStyles] = useState(DEFAULT_BUTTON_STYLES); + const [expandedLabels, setExpandedLabels] = useState>(new Set()); + const savedStylesRef = useRef(DEFAULT_BUTTON_STYLES); + const draftStylesRef = useRef(draftStyles); + draftStylesRef.current = draftStyles; + + useEffect(() => { + if (serverStyles) { + if ( + stylesEqual(savedStylesRef.current, draftStylesRef.current) || + stylesEqual(savedStylesRef.current, DEFAULT_BUTTON_STYLES) + ) { + setDraftStyles(serverStyles); + savedStylesRef.current = serverStyles; + } + } + }, [serverStyles]); + + const hasUnsavedChanges = !stylesEqual(draftStyles, savedStylesRef.current); + + const updateMutation = useMutation({ + mutationFn: buttonStylesApi.updateStyles, + onSuccess: (data) => { + savedStylesRef.current = data; + setDraftStyles(data); + queryClient.setQueryData(['button-styles'], data); + }, + onError: () => { + notify.error(t('common.error')); + }, + }); + + const resetMutation = useMutation({ + mutationFn: buttonStylesApi.resetStyles, + onSuccess: (data) => { + savedStylesRef.current = data; + setDraftStyles(data); + queryClient.setQueryData(['button-styles'], data); + }, + onError: () => { + notify.error(t('common.error')); + }, + }); + + const updateSection = useCallback( + (section: ButtonSection, field: 'style' | 'icon_custom_emoji_id', value: string) => { + setDraftStyles((prev) => ({ + ...prev, + [section]: { + ...prev[section], + [field]: value, + }, + })); + }, + [], + ); + + const toggleEnabled = useCallback((section: ButtonSection) => { + setDraftStyles((prev) => ({ + ...prev, + [section]: { + ...prev[section], + enabled: !prev[section].enabled, + }, + })); + }, []); + + const updateLabel = useCallback((section: ButtonSection, locale: string, value: string) => { + setDraftStyles((prev) => ({ + ...prev, + [section]: { + ...prev[section], + labels: { + ...prev[section].labels, + [locale]: value, + }, + }, + })); + }, []); + + const toggleLabelsExpanded = useCallback((section: string) => { + setExpandedLabels((prev) => { + const next = new Set(prev); + if (next.has(section)) { + next.delete(section); + } else { + next.add(section); + } + return next; + }); + }, []); + + const handleCancel = useCallback(() => { + setDraftStyles(savedStylesRef.current); + }, []); + + const handleSave = useCallback(() => { + const update: Record> = {}; + for (const section of BUTTON_SECTIONS) { + const draft = draftStyles[section]; + const saved = savedStylesRef.current[section]; + const sectionUpdate: Record = {}; + let changed = false; + + if (draft.style !== saved.style) { + sectionUpdate.style = draft.style; + changed = true; + } + if (draft.icon_custom_emoji_id !== saved.icon_custom_emoji_id) { + sectionUpdate.icon_custom_emoji_id = draft.icon_custom_emoji_id; + changed = true; + } + if (draft.enabled !== saved.enabled) { + sectionUpdate.enabled = draft.enabled; + changed = true; + } + if (!labelsEqual(draft.labels, saved.labels)) { + const cleanLabels: Record = {}; + for (const locale of BOT_LOCALES) { + cleanLabels[locale] = (draft.labels[locale] || '').trim(); + } + sectionUpdate.labels = cleanLabels; + changed = true; + } + + if (changed) { + update[section] = sectionUpdate; + } + } + if (Object.keys(update).length > 0) { + updateMutation.mutate(update); + } + }, [draftStyles, updateMutation]); + + return ( +
+ {/* Section cards */} +
+ {BUTTON_SECTIONS.map((section) => { + const cfg = draftStyles[section]; + const isExpanded = expandedLabels.has(section); + const hasCustomLabels = BOT_LOCALES.some((l) => (cfg.labels[l] || '').trim()); + + return ( +
+ {/* Header */} +
+
+
+

+ {t(`admin.buttons.sections.${section}`)} +

+ {!cfg.enabled && ( + + {t('admin.buttons.hidden')} + + )} +
+

+ {t(`admin.buttons.descriptions.${section}`)} +

+
+
+ {/* Live preview chip */} +
+ {t(`admin.buttons.styles.${cfg.style}`)} +
+ {/* Enabled toggle */} + toggleEnabled(section)} /> +
+
+ + {/* Color selector chips */} +
+ +
+ {STYLE_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* Emoji ID input */} +
+ + updateSection(section, 'icon_custom_emoji_id', e.target.value)} + placeholder={t('admin.buttons.emojiPlaceholder')} + className="w-full rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-2 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none" + /> +
+ + {/* Custom labels */} +
+ + {isExpanded && ( +
+ {BOT_LOCALES.map((locale) => ( +
+ + {locale} + + updateLabel(section, locale, e.target.value)} + placeholder={t('admin.buttons.labelPlaceholder')} + maxLength={100} + className="w-full rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-1.5 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none" + /> +
+ ))} +

{t('admin.buttons.labelsHint')}

+
+ )} +
+
+ ); + })} +
+ + {/* Save / Cancel */} + {hasUnsavedChanges && ( +
+ + +
+ )} + + {/* Reset */} +
+ +
+
+ ); +} diff --git a/src/components/admin/constants.ts b/src/components/admin/constants.ts index d35fee9..30ab6a4 100644 --- a/src/components/admin/constants.ts +++ b/src/components/admin/constants.ts @@ -21,6 +21,7 @@ export const MENU_SECTIONS: MenuSection[] = [ { id: 'branding', iconType: null }, { id: 'theme', iconType: null }, { id: 'analytics', iconType: null }, + { id: 'buttons', iconType: null }, ], }, { diff --git a/src/components/admin/index.ts b/src/components/admin/index.ts index 4fc1815..9771d97 100644 --- a/src/components/admin/index.ts +++ b/src/components/admin/index.ts @@ -6,6 +6,7 @@ export * from './SettingInput'; export * from './SettingRow'; export * from './AnalyticsTab'; export * from './BrandingTab'; +export * from './ButtonsTab'; export * from './ThemeTab'; export * from './FavoritesTab'; export * from './SettingsTab'; diff --git a/src/locales/en.json b/src/locales/en.json index ab88e42..cff5a8e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1217,6 +1217,7 @@ "noSearchResults": "Nothing found", "example": "Example", "analytics": "Analytics", + "buttons": "Buttons", "notConfigured": "Not configured", "counterActive": "Active", "counterInactive": "Inactive", @@ -1286,6 +1287,41 @@ "quickPresets": "Quick presets", "saturation": "Saturation" }, + "buttons": { + "color": "Button color", + "emojiId": "Custom Emoji ID", + "emojiPlaceholder": "Custom emoji ID (optional)", + "resetAll": "Reset all styles", + "resetConfirm": "Reset all button styles to defaults?", + "hidden": "Hidden", + "customLabels": "Button labels", + "labelPlaceholder": "Custom button text", + "labelsHint": "Empty = default label", + "sections": { + "home": "Home", + "subscription": "Subscription", + "balance": "Balance", + "referral": "Referrals", + "support": "Support", + "info": "Info", + "admin": "Admin Panel" + }, + "descriptions": { + "home": "Personal cabinet button", + "subscription": "Subscription management", + "balance": "Top-up and balance", + "referral": "Referral program", + "support": "Support tickets", + "info": "Information section", + "admin": "Web admin panel" + }, + "styles": { + "default": "No color", + "primary": "Blue", + "success": "Green", + "danger": "Red" + } + }, "apps": { "title": "App Management", "addApp": "Add App", diff --git a/src/locales/fa.json b/src/locales/fa.json index 1490fcd..28f3377 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -935,6 +935,7 @@ "noSearchResults": "چیزی یافت نشد", "example": "مثال", "analytics": "تحلیل‌ها", + "buttons": "دکمه‌ها", "notConfigured": "پیکربندی نشده", "counterActive": "فعال", "counterInactive": "غیرفعال", @@ -995,6 +996,41 @@ "resetAllColors": "بازنشانی همه رنگ‌ها", "statusColors": "رنگ‌های وضعیت" }, + "buttons": { + "color": "رنگ دکمه", + "emojiId": "شناسه ایموجی سفارشی", + "emojiPlaceholder": "شناسه ایموجی سفارشی (اختیاری)", + "resetAll": "بازنشانی همه سبک‌ها", + "resetConfirm": "همه سبک‌های دکمه به حالت پیش‌فرض بازنشانی شوند؟", + "hidden": "پنهان", + "customLabels": "نام‌های دکمه", + "labelPlaceholder": "متن سفارشی دکمه", + "labelsHint": "خالی = نام پیش‌فرض", + "sections": { + "home": "صفحه اصلی", + "subscription": "اشتراک", + "balance": "موجودی", + "referral": "معرفی", + "support": "پشتیبانی", + "info": "اطلاعات", + "admin": "پنل مدیریت" + }, + "descriptions": { + "home": "دکمه کابینت شخصی", + "subscription": "مدیریت اشتراک", + "balance": "شارژ و موجودی", + "referral": "برنامه معرفی", + "support": "تیکت‌های پشتیبانی", + "info": "بخش اطلاعات", + "admin": "پنل مدیریت وب" + }, + "styles": { + "default": "بدون رنگ", + "primary": "آبی", + "success": "سبز", + "danger": "قرمز" + } + }, "apps": { "title": "مدیریت برنامه‌ها", "addApp": "افزودن برنامه", diff --git a/src/locales/ru.json b/src/locales/ru.json index e567be5..af829b7 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1260,6 +1260,7 @@ "branding": "Брендинг", "theme": "Тема", "analytics": "Аналитика", + "buttons": "Кнопки", "payments": "Платежи", "subscriptions": "Подписки", "interface": "Интерфейс", @@ -1801,6 +1802,41 @@ "MODERATION": "Модерация" } }, + "buttons": { + "color": "Цвет кнопки", + "emojiId": "Custom Emoji ID", + "emojiPlaceholder": "ID кастомного эмодзи (необязательно)", + "resetAll": "Сбросить все стили", + "resetConfirm": "Сбросить все стили кнопок к значениям по умолчанию?", + "hidden": "Скрыта", + "customLabels": "Названия кнопки", + "labelPlaceholder": "Свой текст кнопки", + "labelsHint": "Пустое поле = название по умолчанию", + "sections": { + "home": "Главная", + "subscription": "Подписка", + "balance": "Баланс", + "referral": "Рефералы", + "support": "Поддержка", + "info": "Информация", + "admin": "Админка" + }, + "descriptions": { + "home": "Кнопка личного кабинета", + "subscription": "Управление подпиской", + "balance": "Пополнение и баланс", + "referral": "Реферальная программа", + "support": "Обращения в поддержку", + "info": "Информационный раздел", + "admin": "Веб-админка" + }, + "styles": { + "default": "Без цвета", + "primary": "Синий", + "success": "Зелёный", + "danger": "Красный" + } + }, "apps": { "title": "Управление приложениями", "addApp": "Добавить приложение", diff --git a/src/locales/zh.json b/src/locales/zh.json index 0b7eaa9..7c54171 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -973,6 +973,7 @@ "noSearchResults": "未找到结果", "example": "示例", "analytics": "分析", + "buttons": "按钮", "notConfigured": "未配置", "counterActive": "已启用", "counterInactive": "未启用", @@ -1033,6 +1034,41 @@ "resetAllColors": "重置所有颜色", "statusColors": "状态颜色" }, + "buttons": { + "color": "按钮颜色", + "emojiId": "自定义表情 ID", + "emojiPlaceholder": "自定义表情 ID(可选)", + "resetAll": "重置所有样式", + "resetConfirm": "将所有按钮样式重置为默认值?", + "hidden": "已隐藏", + "customLabels": "按钮名称", + "labelPlaceholder": "自定义按钮文本", + "labelsHint": "留空 = 默认名称", + "sections": { + "home": "主页", + "subscription": "订阅", + "balance": "余额", + "referral": "推荐", + "support": "支持", + "info": "信息", + "admin": "管理面板" + }, + "descriptions": { + "home": "个人中心按钮", + "subscription": "订阅管理", + "balance": "充值和余额", + "referral": "推荐计划", + "support": "支持工单", + "info": "信息板块", + "admin": "网页管理面板" + }, + "styles": { + "default": "无颜色", + "primary": "蓝色", + "success": "绿色", + "danger": "红色" + } + }, "apps": { "title": "应用管理", "addApp": "添加应用", diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx index 26ba607..9b429ed 100644 --- a/src/pages/AdminSettings.tsx +++ b/src/pages/AdminSettings.tsx @@ -9,6 +9,7 @@ import { MENU_SECTIONS, MenuItem, formatSettingKey } from '../components/admin'; import { usePlatform } from '../platform/hooks/usePlatform'; import { AnalyticsTab } from '../components/admin/AnalyticsTab'; import { BrandingTab } from '../components/admin/BrandingTab'; +import { ButtonsTab } from '../components/admin/ButtonsTab'; import { ThemeTab } from '../components/admin/ThemeTab'; import { FavoritesTab } from '../components/admin/FavoritesTab'; import { SettingsTab } from '../components/admin/SettingsTab'; @@ -174,6 +175,8 @@ export default function AdminSettings() { return ; case 'theme': return ; + case 'buttons': + return ; case 'favorites': return ( ( ); +/** + * Calculate rotation degrees for a prize based on its position on the wheel. + * Mirrors the backend _calculate_rotation logic in wheel_service.py. + */ +function calculateRotationForPrize(prizes: WheelPrize[], result: SpinResult): number { + // Find prize index by matching display_name + emoji (both must match for safety) + let prizeIndex = prizes.findIndex( + (p) => p.display_name === result.prize_display_name && p.emoji === result.emoji, + ); + + // Fallback: match by display_name only + if (prizeIndex === -1) { + prizeIndex = prizes.findIndex((p) => p.display_name === result.prize_display_name); + } + + // Fallback: match by emoji + prize_type + if (prizeIndex === -1) { + prizeIndex = prizes.findIndex( + (p) => p.emoji === result.emoji && p.prize_type === result.prize_type, + ); + } + + if (prizeIndex === -1) { + // Can't determine prize position — random angle + return Math.random() * 360; + } + + const sectorAngle = 360 / prizes.length; + const baseAngle = prizeIndex * sectorAngle + sectorAngle / 2; + const offset = (Math.random() - 0.5) * sectorAngle * 0.6; // ±30% of sector + const stopAngle = 360 - baseAngle + offset; + + return stopAngle; +} + export default function Wheel() { const { t } = useTranslation(); const queryClient = useQueryClient(); @@ -198,15 +233,9 @@ export default function Wheel() { } pollingAbortRef.current = new AbortController(); - // Payment done - reset paying state immediately - setIsPayingStars(false); - - // Start spinning animation (5 seconds duration in FortuneWheel) - setIsSpinning(true); - setTargetRotation(360 * 5 + Math.random() * 360); - - // Poll for the result in the background - don't await here! - // The result will be stored and shown when animation completes + // Keep isPayingStars=true to show loading state while polling for result. + // We poll FIRST to get the actual prize, then calculate the correct + // rotation angle so the wheel visually lands on the right sector. const abortSignal = pollingAbortRef.current.signal; pollForSpinResult(abortSignal) .then((result) => { @@ -215,10 +244,17 @@ export default function Wheel() { queryClient.invalidateQueries({ queryKey: ['wheel-config'] }); queryClient.invalidateQueries({ queryKey: ['wheel-history'] }); + setIsPayingStars(false); + if (result) { pendingStarsResultRef.current = result; + // Calculate rotation so the wheel lands on the correct prize sector + const rotation = config?.prizes + ? calculateRotationForPrize(config.prizes, result) + : Math.random() * 360; + setTargetRotation(rotation); } else { - // Fallback: couldn't get result + // Fallback: couldn't get result — use random rotation pendingStarsResultRef.current = { success: true, prize_id: null, @@ -232,12 +268,17 @@ export default function Wheel() { promocode: null, error: null, }; + setTargetRotation(Math.random() * 360); } + + setIsSpinning(true); }) .catch(() => { if (abortSignal.aborted) return; - // Error polling, show generic success + setIsPayingStars(false); + + // Error polling — spin with random rotation and show generic message pendingStarsResultRef.current = { success: true, prize_id: null, @@ -251,6 +292,8 @@ export default function Wheel() { promocode: null, error: null, }; + setTargetRotation(Math.random() * 360); + setIsSpinning(true); }); } else if (status !== 'cancelled') { setIsPayingStars(false);