mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
506
README.md
506
README.md
@@ -1,263 +1,153 @@
|
|||||||
# Bedolaga Cabinet - Web Interface
|
# 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).
|
||||||
|
|
||||||
## Возможности
|
React + Vite + TypeScript | Авторизация через Telegram | Мультиязычность (EN/RU) | Адаптивный дизайн
|
||||||
|
|
||||||
- 🔐 Авторизация через Telegram
|
|
||||||
- 💳 Пополнение баланса (YooKassa, CryptoBot, Stars и др.)
|
|
||||||
- 📊 Управление подписками и ключами
|
|
||||||
- 🎫 Система тикетов поддержки
|
|
||||||
- 🌐 Мультиязычность (EN/RU)
|
|
||||||
- 📱 Адаптивный дизайн
|
|
||||||
- 🎨 Настраиваемый брендинг
|
|
||||||
- Админпанель для управлени ботом
|
|
||||||
- Автоматическая передача subpage конфигов приложений из Remnawave
|
|
||||||
- ⚡ Fast - React + Vite + TypeScript
|
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
||||||
- Node.js 18+ (для разработки)
|
- Docker и Docker Compose
|
||||||
- Docker и Docker Compose (для production)
|
|
||||||
- Запущенный backend бота с включенным Cabinet API
|
- Запущенный backend бота с включенным Cabinet API
|
||||||
|
- Обратный прокси (Caddy / Nginx / Traefik)
|
||||||
|
|
||||||
## Быстрый старт
|
## Архитектура
|
||||||
|
|
||||||
### Вариант A: Готовый Docker образ
|
```
|
||||||
|
Браузер → Caddy/Nginx → /api/* → remnawave_bot:8080 (backend API)
|
||||||
```bash
|
→ /* → /srv/cabinet (статика frontend)
|
||||||
docker pull ghcr.io/bedolaga-dev/bedolaga-cabinet:latest
|
|
||||||
|
|
||||||
или
|
|
||||||
|
|
||||||
docker pull bedolaga/bedolaga-cabinet:latest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Затем настройте Caddy/Nginx для проксирования (см. раздел "Настройка прокси для production").
|
Frontend — это статические файлы (HTML, JS, CSS). Обратный прокси выполняет две задачи:
|
||||||
|
1. Раздает статику frontend
|
||||||
|
2. Проксирует `/api/*` запросы на backend бота (с удалением префикса `/api`)
|
||||||
|
|
||||||
### Вариант B: Сборка из исходников
|
## Установка
|
||||||
|
|
||||||
#### 1. Клонирование репозитория
|
### Шаг 1. Настройка backend бота
|
||||||
|
|
||||||
```bash
|
В `.env` файле бота добавьте:
|
||||||
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 API
|
||||||
CABINET_ENABLED=true
|
CABINET_ENABLED=true
|
||||||
|
|
||||||
# JWT секрет для авторизации (сгенерируйте случайную строку)
|
# JWT секрет (сгенерируйте случайную строку: openssl rand -hex 32)
|
||||||
CABINET_JWT_SECRET=your_random_secret_key_here
|
CABINET_JWT_SECRET=your_random_secret_key_here
|
||||||
|
|
||||||
# Разрешенные origins для CORS
|
# Разрешенные origins для CORS (домен, на котором будет кабинет)
|
||||||
CABINET_ALLOWED_ORIGINS=http://localhost:3020,https://cabinet.yourdomain.com
|
CABINET_ALLOWED_ORIGINS=https://cabinet.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
После изменений перезапустите бота.
|
Перезапустите бота после изменений.
|
||||||
|
|
||||||
## Настройка прокси для production
|
### Шаг 2. Получение frontend файлов
|
||||||
|
|
||||||
Frontend - это статические файлы (HTML, JS, CSS). Для работы нужно:
|
#### Вариант A: Готовый Docker образ (рекомендуется)
|
||||||
|
|
||||||
1. Раздавать статику через веб-сервер
|
|
||||||
2. Проксировать `/api/*` запросы на backend бота
|
|
||||||
|
|
||||||
> **💡 Важно:** Docker контейнер из этого репозитория содержит nginx, который слушает на **внутреннем порту 80**.
|
|
||||||
> Это НЕ хост-порт! Выберите один из вариантов ниже в зависимости от вашей инфраструктуры.
|
|
||||||
|
|
||||||
### Вариант 1: Caddy раздает статику напрямую
|
|
||||||
|
|
||||||
**✅ Рекомендуется** - без лишних слоев прокси, максимальная производительность.
|
|
||||||
|
|
||||||
Соберите frontend и примонтируйте в Caddy:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Соберите образ или скопируйте dist из контейнера
|
docker pull ghcr.io/bedolaga-dev/bedolaga-cabinet:latest
|
||||||
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:
|
Извлеките собранные файлы из образа:
|
||||||
|
|
||||||
|
```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
|
```caddyfile
|
||||||
cabinet.yourdomain.com {
|
https://cabinet.example.com {
|
||||||
root * /srv/cabinet
|
encode gzip zstd
|
||||||
encode gzip
|
|
||||||
|
|
||||||
# API запросы на backend
|
# API запросы → backend бота (удаляет /api префикс)
|
||||||
handle /api/* {
|
handle /api/* {
|
||||||
uri strip_prefix /api
|
uri strip_prefix /api
|
||||||
reverse_proxy remnawave_bot:8080
|
reverse_proxy remnawave_bot:8080
|
||||||
}
|
}
|
||||||
@websockets {
|
|
||||||
header_regexp Connection *Upgrade*
|
|
||||||
header Upgrade websocket
|
|
||||||
}
|
|
||||||
|
|
||||||
# WebSocket соединения
|
# Frontend статика
|
||||||
handle /cabinet/ws {
|
|
||||||
uri strip_prefix /api
|
|
||||||
reverse_proxy remnawave_bot:8080 {
|
|
||||||
transport http {
|
|
||||||
read_timeout 0
|
|
||||||
write_timeout 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Статические файлы
|
|
||||||
handle {
|
handle {
|
||||||
|
root * /srv/cabinet
|
||||||
try_files {path} /index.html
|
try_files {path} /index.html
|
||||||
file_server
|
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:
|
# HTML без кэша (для обновлений SPA)
|
||||||
|
@html path *.html /
|
||||||
```yaml
|
header @html Cache-Control "no-cache, must-revalidate"
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Nginx (добавьте в существующий конфиг):
|
> **Примечание:** `remnawave_bot:8080` — имя контейнера бота в Docker сети.
|
||||||
|
> Если Caddy запущен на хосте, а не в Docker, используйте `localhost:8080` или IP сервера.
|
||||||
|
|
||||||
|
#### Nginx
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
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;
|
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/ {
|
location /api/ {
|
||||||
rewrite ^/api/(.*) /$1 break;
|
rewrite ^/api/(.*) /$1 break;
|
||||||
proxy_pass http://remnawave_bot:8080;
|
proxy_pass http://remnawave_bot:8080;
|
||||||
@@ -268,28 +158,38 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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 / {
|
location / {
|
||||||
proxy_pass http://cabinet_frontend:80;
|
try_files $uri /index.html;
|
||||||
proxy_set_header Host $host;
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Шаг 5:** Перезагрузите Caddy/Nginx:
|
### Шаг 5. Запуск и проверка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Для Caddy
|
# Перезагрузите Caddy
|
||||||
docker exec <caddy_container> caddy reload --config /etc/caddy/Caddyfile
|
docker exec <caddy_container> caddy reload --config /etc/caddy/Caddyfile
|
||||||
|
|
||||||
# Для Nginx
|
# Или Nginx
|
||||||
docker exec <nginx_container> nginx -s reload
|
docker exec <nginx_container> nginx -s reload
|
||||||
```
|
```
|
||||||
|
|
||||||
#### B. Если Caddy/Nginx ещё НЕ запущен:
|
Откройте `https://cabinet.example.com` в браузере.
|
||||||
|
|
||||||
docker-compose.yml:
|
## Альтернативная установка: Docker контейнер с nginx внутри
|
||||||
|
|
||||||
|
Если вы не хотите раздавать статику напрямую, можно проксировать запросы на Docker контейнер cabinet.
|
||||||
|
|
||||||
|
### docker-compose.yml
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -297,106 +197,136 @@ services:
|
|||||||
image: ghcr.io/bedolaga-dev/bedolaga-cabinet:latest
|
image: ghcr.io/bedolaga-dev/bedolaga-cabinet:latest
|
||||||
container_name: cabinet_frontend
|
container_name: cabinet_frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Можно открыть порт для прямого доступа (для тестирования)
|
# Порты НЕ пробрасываем — доступ только через Docker сеть
|
||||||
# ports:
|
|
||||||
# - "3020:80"
|
|
||||||
networks:
|
networks:
|
||||||
- web
|
- bot_network
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
web:
|
bot_network:
|
||||||
driver: bridge
|
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_API_URL` | Путь к API (`/api` или полный URL) | `/api` |
|
||||||
| `VITE_TELEGRAM_BOT_USERNAME` | Username Telegram бота (без @) | - |
|
| `VITE_TELEGRAM_BOT_USERNAME` | Username Telegram бота (без @) | — |
|
||||||
| `VITE_APP_NAME` | Название приложения | `Cabinet` |
|
| `VITE_APP_NAME` | Название в шапке и вкладке браузера | `Cabinet` |
|
||||||
| `VITE_APP_LOGO` | Логотип (короткий текст) | `V` |
|
| `VITE_APP_LOGO` | Текст логотипа (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 <container> --format='{{json .NetworkSettings.Networks}}' | python3 -m json.tool
|
||||||
|
|
||||||
|
# Подключить к нужной сети
|
||||||
|
docker network connect <network_name> <container_name>
|
||||||
|
```
|
||||||
|
3. Неправильное имя сервиса в прокси — проверьте через:
|
||||||
|
```bash
|
||||||
|
docker exec <caddy_container> 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/
|
bedolaga-cabinet/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── api/ # API клиенты
|
│ ├── api/ # API клиенты (axios)
|
||||||
│ ├── components/ # React компоненты
|
│ ├── components/ # React компоненты (UI kit)
|
||||||
│ ├── contexts/ # React контексты
|
│ ├── contexts/ # React контексты (auth, theme)
|
||||||
│ ├── hooks/ # Custom hooks
|
│ ├── hooks/ # Custom hooks
|
||||||
│ ├── locales/ # Переводы (i18n)
|
│ ├── locales/ # Переводы (i18n)
|
||||||
│ ├── pages/ # Страницы приложения
|
│ ├── pages/ # Страницы приложения
|
||||||
│ ├── types/ # TypeScript типы
|
│ ├── types/ # TypeScript типы
|
||||||
│ └── utils/ # Утилиты
|
│ └── utils/ # Утилиты
|
||||||
├── public/ # Статические файлы
|
├── public/ # Статические файлы
|
||||||
├── Dockerfile # Docker образ
|
├── Dockerfile # Multi-stage сборка (node → nginx)
|
||||||
├── docker-compose.yml # Docker Compose конфигурация
|
├── docker-compose.yml # Docker Compose для сборки
|
||||||
|
├── nginx.conf # Nginx конфиг внутри контейнера
|
||||||
└── .env.example # Пример переменных окружения
|
└── .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 <caddy_container> wget -qO- http://cabinet_frontend:80
|
|
||||||
|
|
||||||
# Проверить что backend доступен
|
|
||||||
docker exec <caddy_container> wget -qO- http://remnawave_bot:8080/health
|
|
||||||
|
|
||||||
# Проверить в какой сети находятся контейнеры
|
|
||||||
docker inspect cabinet_frontend | grep -A 10 Networks
|
|
||||||
docker inspect <caddy_container> | grep -A 10 Networks
|
|
||||||
```
|
|
||||||
|
|
||||||
Если контейнеры в разных сетях, подключите их:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Подключить frontend к сети Caddy
|
|
||||||
docker network connect <caddy_network> cabinet_frontend
|
|
||||||
|
|
||||||
# ИЛИ подключить Caddy к сети frontend
|
|
||||||
docker network connect <frontend_network> <caddy_container>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Telegram авторизация не работает
|
|
||||||
|
|
||||||
1. Проверьте `VITE_TELEGRAM_BOT_USERNAME` - должен быть без `@`
|
|
||||||
2. Убедитесь что домен добавлен в Bot Settings → Domain
|
|
||||||
|
|
||||||
## Связанные проекты
|
## Связанные проекты
|
||||||
|
|
||||||
- [Remnawave Bedolaga Telegram Bot](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot) - Backend бота
|
- [Remnawave Bedolaga Telegram Bot](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot) — Backend бота
|
||||||
- [Bedolaga Chat](https://t.me/+wTdMtSWq8YdmZmVi) - Чат поддержки
|
- [Bedolaga Chat](https://t.me/+wTdMtSWq8YdmZmVi) — Чат поддержки
|
||||||
|
|
||||||
## Контакты
|
## Контакты
|
||||||
|
|
||||||
|
|||||||
92
src/api/buttonStyles.ts
Normal file
92
src/api/buttonStyles.ts
Normal file
@@ -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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ButtonSectionConfig>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<ButtonStylesConfig> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<ButtonStylesConfig>('/cabinet/admin/button-styles');
|
||||||
|
return normalizeConfig(response.data);
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_BUTTON_STYLES;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateStyles: async (update: ButtonStylesUpdate): Promise<ButtonStylesConfig> => {
|
||||||
|
const response = await apiClient.patch<ButtonStylesConfig>(
|
||||||
|
'/cabinet/admin/button-styles',
|
||||||
|
update,
|
||||||
|
);
|
||||||
|
return normalizeConfig(response.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetStyles: async (): Promise<ButtonStylesConfig> => {
|
||||||
|
const response = await apiClient.post<ButtonStylesConfig>('/cabinet/admin/button-styles/reset');
|
||||||
|
return normalizeConfig(response.data);
|
||||||
|
},
|
||||||
|
};
|
||||||
357
src/components/admin/ButtonsTab.tsx
Normal file
357
src/components/admin/ButtonsTab.tsx
Normal file
@@ -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<string, string>, b: Record<string, string>): 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<ButtonStylesConfig>(DEFAULT_BUTTON_STYLES);
|
||||||
|
const [expandedLabels, setExpandedLabels] = useState<Set<string>>(new Set());
|
||||||
|
const savedStylesRef = useRef<ButtonStylesConfig>(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<string, Record<string, unknown>> = {};
|
||||||
|
for (const section of BUTTON_SECTIONS) {
|
||||||
|
const draft = draftStyles[section];
|
||||||
|
const saved = savedStylesRef.current[section];
|
||||||
|
const sectionUpdate: Record<string, unknown> = {};
|
||||||
|
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<string, string> = {};
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Section cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
{BUTTON_SECTIONS.map((section) => {
|
||||||
|
const cfg = draftStyles[section];
|
||||||
|
const isExpanded = expandedLabels.has(section);
|
||||||
|
const hasCustomLabels = BOT_LOCALES.some((l) => (cfg.labels[l] || '').trim());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={section}
|
||||||
|
className={`overflow-hidden rounded-2xl border bg-dark-800/50 p-4 transition-colors sm:p-5 ${
|
||||||
|
cfg.enabled ? 'border-dark-700/50' : 'border-dark-700/30 opacity-60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="truncate text-sm font-semibold text-dark-100">
|
||||||
|
{t(`admin.buttons.sections.${section}`)}
|
||||||
|
</h4>
|
||||||
|
{!cfg.enabled && (
|
||||||
|
<span className="shrink-0 rounded bg-dark-600 px-1.5 py-0.5 text-[10px] font-medium text-dark-400">
|
||||||
|
{t('admin.buttons.hidden')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 truncate text-xs text-dark-400">
|
||||||
|
{t(`admin.buttons.descriptions.${section}`)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{/* Live preview chip */}
|
||||||
|
<div
|
||||||
|
className={`whitespace-nowrap rounded-lg px-2.5 py-1.5 text-xs font-medium ${
|
||||||
|
cfg.style === 'default'
|
||||||
|
? 'bg-dark-600 text-dark-300'
|
||||||
|
: cfg.style === 'success'
|
||||||
|
? 'bg-green-500 text-white'
|
||||||
|
: cfg.style === 'danger'
|
||||||
|
? 'bg-red-500 text-white'
|
||||||
|
: 'bg-blue-500 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`admin.buttons.styles.${cfg.style}`)}
|
||||||
|
</div>
|
||||||
|
{/* Enabled toggle */}
|
||||||
|
<Toggle checked={cfg.enabled} onChange={() => toggleEnabled(section)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color selector chips */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-dark-300">
|
||||||
|
{t('admin.buttons.color')}
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{STYLE_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => updateSection(section, 'style', opt.value)}
|
||||||
|
className={`flex h-7 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-all ${
|
||||||
|
cfg.style === opt.value
|
||||||
|
? 'border-accent-500 bg-accent-500/10 text-accent-400'
|
||||||
|
: 'border-dark-600 bg-dark-700/50 text-dark-300 hover:border-dark-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${opt.colorClass}`} />
|
||||||
|
{t(`admin.buttons.styles.${opt.value}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Emoji ID input */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-dark-300">
|
||||||
|
{t('admin.buttons.emojiId')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={cfg.icon_custom_emoji_id}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom labels */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleLabelsExpanded(section)}
|
||||||
|
className="flex w-full items-center justify-between text-xs font-medium text-dark-300 transition-colors hover:text-dark-200"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
{t('admin.buttons.customLabels')}
|
||||||
|
{hasCustomLabels && <span className="h-1.5 w-1.5 rounded-full bg-accent-500" />}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`h-3.5 w-3.5 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{BOT_LOCALES.map((locale) => (
|
||||||
|
<div key={locale} className="flex items-center gap-2">
|
||||||
|
<span className="w-7 shrink-0 text-center text-[10px] font-semibold uppercase text-dark-500">
|
||||||
|
{locale}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={cfg.labels[locale] || ''}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="text-[10px] text-dark-500">{t('admin.buttons.labelsHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save / Cancel */}
|
||||||
|
{hasUnsavedChanges && (
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
className="rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updateMutation.isPending ? t('common.saving') : t('common.save')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
className="rounded-xl bg-dark-700 px-4 py-2 text-sm font-medium text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reset */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(t('admin.buttons.resetConfirm'))) {
|
||||||
|
resetMutation.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={resetMutation.isPending}
|
||||||
|
className="rounded-xl bg-dark-700 px-4 py-2 text-sm text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('admin.buttons.resetAll')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export const MENU_SECTIONS: MenuSection[] = [
|
|||||||
{ id: 'branding', iconType: null },
|
{ id: 'branding', iconType: null },
|
||||||
{ id: 'theme', iconType: null },
|
{ id: 'theme', iconType: null },
|
||||||
{ id: 'analytics', iconType: null },
|
{ id: 'analytics', iconType: null },
|
||||||
|
{ id: 'buttons', iconType: null },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export * from './SettingInput';
|
|||||||
export * from './SettingRow';
|
export * from './SettingRow';
|
||||||
export * from './AnalyticsTab';
|
export * from './AnalyticsTab';
|
||||||
export * from './BrandingTab';
|
export * from './BrandingTab';
|
||||||
|
export * from './ButtonsTab';
|
||||||
export * from './ThemeTab';
|
export * from './ThemeTab';
|
||||||
export * from './FavoritesTab';
|
export * from './FavoritesTab';
|
||||||
export * from './SettingsTab';
|
export * from './SettingsTab';
|
||||||
|
|||||||
@@ -1217,6 +1217,7 @@
|
|||||||
"noSearchResults": "Nothing found",
|
"noSearchResults": "Nothing found",
|
||||||
"example": "Example",
|
"example": "Example",
|
||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
|
"buttons": "Buttons",
|
||||||
"notConfigured": "Not configured",
|
"notConfigured": "Not configured",
|
||||||
"counterActive": "Active",
|
"counterActive": "Active",
|
||||||
"counterInactive": "Inactive",
|
"counterInactive": "Inactive",
|
||||||
@@ -1286,6 +1287,41 @@
|
|||||||
"quickPresets": "Quick presets",
|
"quickPresets": "Quick presets",
|
||||||
"saturation": "Saturation"
|
"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": {
|
"apps": {
|
||||||
"title": "App Management",
|
"title": "App Management",
|
||||||
"addApp": "Add App",
|
"addApp": "Add App",
|
||||||
|
|||||||
@@ -935,6 +935,7 @@
|
|||||||
"noSearchResults": "چیزی یافت نشد",
|
"noSearchResults": "چیزی یافت نشد",
|
||||||
"example": "مثال",
|
"example": "مثال",
|
||||||
"analytics": "تحلیلها",
|
"analytics": "تحلیلها",
|
||||||
|
"buttons": "دکمهها",
|
||||||
"notConfigured": "پیکربندی نشده",
|
"notConfigured": "پیکربندی نشده",
|
||||||
"counterActive": "فعال",
|
"counterActive": "فعال",
|
||||||
"counterInactive": "غیرفعال",
|
"counterInactive": "غیرفعال",
|
||||||
@@ -995,6 +996,41 @@
|
|||||||
"resetAllColors": "بازنشانی همه رنگها",
|
"resetAllColors": "بازنشانی همه رنگها",
|
||||||
"statusColors": "رنگهای وضعیت"
|
"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": {
|
"apps": {
|
||||||
"title": "مدیریت برنامهها",
|
"title": "مدیریت برنامهها",
|
||||||
"addApp": "افزودن برنامه",
|
"addApp": "افزودن برنامه",
|
||||||
|
|||||||
@@ -1260,6 +1260,7 @@
|
|||||||
"branding": "Брендинг",
|
"branding": "Брендинг",
|
||||||
"theme": "Тема",
|
"theme": "Тема",
|
||||||
"analytics": "Аналитика",
|
"analytics": "Аналитика",
|
||||||
|
"buttons": "Кнопки",
|
||||||
"payments": "Платежи",
|
"payments": "Платежи",
|
||||||
"subscriptions": "Подписки",
|
"subscriptions": "Подписки",
|
||||||
"interface": "Интерфейс",
|
"interface": "Интерфейс",
|
||||||
@@ -1801,6 +1802,41 @@
|
|||||||
"MODERATION": "Модерация"
|
"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": {
|
"apps": {
|
||||||
"title": "Управление приложениями",
|
"title": "Управление приложениями",
|
||||||
"addApp": "Добавить приложение",
|
"addApp": "Добавить приложение",
|
||||||
|
|||||||
@@ -973,6 +973,7 @@
|
|||||||
"noSearchResults": "未找到结果",
|
"noSearchResults": "未找到结果",
|
||||||
"example": "示例",
|
"example": "示例",
|
||||||
"analytics": "分析",
|
"analytics": "分析",
|
||||||
|
"buttons": "按钮",
|
||||||
"notConfigured": "未配置",
|
"notConfigured": "未配置",
|
||||||
"counterActive": "已启用",
|
"counterActive": "已启用",
|
||||||
"counterInactive": "未启用",
|
"counterInactive": "未启用",
|
||||||
@@ -1033,6 +1034,41 @@
|
|||||||
"resetAllColors": "重置所有颜色",
|
"resetAllColors": "重置所有颜色",
|
||||||
"statusColors": "状态颜色"
|
"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": {
|
"apps": {
|
||||||
"title": "应用管理",
|
"title": "应用管理",
|
||||||
"addApp": "添加应用",
|
"addApp": "添加应用",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { MENU_SECTIONS, MenuItem, formatSettingKey } from '../components/admin';
|
|||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
|
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
|
||||||
import { BrandingTab } from '../components/admin/BrandingTab';
|
import { BrandingTab } from '../components/admin/BrandingTab';
|
||||||
|
import { ButtonsTab } from '../components/admin/ButtonsTab';
|
||||||
import { ThemeTab } from '../components/admin/ThemeTab';
|
import { ThemeTab } from '../components/admin/ThemeTab';
|
||||||
import { FavoritesTab } from '../components/admin/FavoritesTab';
|
import { FavoritesTab } from '../components/admin/FavoritesTab';
|
||||||
import { SettingsTab } from '../components/admin/SettingsTab';
|
import { SettingsTab } from '../components/admin/SettingsTab';
|
||||||
@@ -174,6 +175,8 @@ export default function AdminSettings() {
|
|||||||
return <BrandingTab accentColor={themeColors?.accent} />;
|
return <BrandingTab accentColor={themeColors?.accent} />;
|
||||||
case 'theme':
|
case 'theme':
|
||||||
return <ThemeTab />;
|
return <ThemeTab />;
|
||||||
|
case 'buttons':
|
||||||
|
return <ButtonsTab />;
|
||||||
case 'favorites':
|
case 'favorites':
|
||||||
return (
|
return (
|
||||||
<FavoritesTab
|
<FavoritesTab
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { wheelApi, type SpinResult, type SpinHistoryItem } from '../api/wheel';
|
import { wheelApi, type WheelPrize, type SpinResult, type SpinHistoryItem } from '../api/wheel';
|
||||||
import FortuneWheel from '../components/wheel/FortuneWheel';
|
import FortuneWheel from '../components/wheel/FortuneWheel';
|
||||||
import WheelLegend from '../components/wheel/WheelLegend';
|
import WheelLegend from '../components/wheel/WheelLegend';
|
||||||
import { usePlatform, useHaptic } from '@/platform';
|
import { usePlatform, useHaptic } from '@/platform';
|
||||||
@@ -56,6 +56,41 @@ const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
export default function Wheel() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -198,15 +233,9 @@ export default function Wheel() {
|
|||||||
}
|
}
|
||||||
pollingAbortRef.current = new AbortController();
|
pollingAbortRef.current = new AbortController();
|
||||||
|
|
||||||
// Payment done - reset paying state immediately
|
// Keep isPayingStars=true to show loading state while polling for result.
|
||||||
setIsPayingStars(false);
|
// We poll FIRST to get the actual prize, then calculate the correct
|
||||||
|
// rotation angle so the wheel visually lands on the right sector.
|
||||||
// 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
|
|
||||||
const abortSignal = pollingAbortRef.current.signal;
|
const abortSignal = pollingAbortRef.current.signal;
|
||||||
pollForSpinResult(abortSignal)
|
pollForSpinResult(abortSignal)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
@@ -215,10 +244,17 @@ export default function Wheel() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['wheel-config'] });
|
queryClient.invalidateQueries({ queryKey: ['wheel-config'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['wheel-history'] });
|
queryClient.invalidateQueries({ queryKey: ['wheel-history'] });
|
||||||
|
|
||||||
|
setIsPayingStars(false);
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
pendingStarsResultRef.current = 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 {
|
} else {
|
||||||
// Fallback: couldn't get result
|
// Fallback: couldn't get result — use random rotation
|
||||||
pendingStarsResultRef.current = {
|
pendingStarsResultRef.current = {
|
||||||
success: true,
|
success: true,
|
||||||
prize_id: null,
|
prize_id: null,
|
||||||
@@ -232,12 +268,17 @@ export default function Wheel() {
|
|||||||
promocode: null,
|
promocode: null,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
setTargetRotation(Math.random() * 360);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsSpinning(true);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (abortSignal.aborted) return;
|
if (abortSignal.aborted) return;
|
||||||
|
|
||||||
// Error polling, show generic success
|
setIsPayingStars(false);
|
||||||
|
|
||||||
|
// Error polling — spin with random rotation and show generic message
|
||||||
pendingStarsResultRef.current = {
|
pendingStarsResultRef.current = {
|
||||||
success: true,
|
success: true,
|
||||||
prize_id: null,
|
prize_id: null,
|
||||||
@@ -251,6 +292,8 @@ export default function Wheel() {
|
|||||||
promocode: null,
|
promocode: null,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
setTargetRotation(Math.random() * 360);
|
||||||
|
setIsSpinning(true);
|
||||||
});
|
});
|
||||||
} else if (status !== 'cancelled') {
|
} else if (status !== 'cancelled') {
|
||||||
setIsPayingStars(false);
|
setIsPayingStars(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user