Init commit
This commit is contained in:
commit
c9fde121be
256
.gitignore
vendored
Normal file
256
.gitignore
vendored
Normal file
@ -0,0 +1,256 @@
|
||||
# Compiled Object files, Static and Dynamic libs (Shared Objects)
|
||||
*.o
|
||||
*.a
|
||||
*.so
|
||||
|
||||
# Folders
|
||||
_obj
|
||||
_test
|
||||
|
||||
# Architecture specific extensions/prefixes
|
||||
*.[568vq]
|
||||
[568vq].out
|
||||
|
||||
*.cgo1.go
|
||||
*.cgo2.c
|
||||
_cgo_defun.c
|
||||
_cgo_gotypes.go
|
||||
_cgo_export.*
|
||||
|
||||
_testmain.go
|
||||
|
||||
*.exe
|
||||
*.test
|
||||
*.prof
|
||||
|
||||
# ignore directories
|
||||
bin/
|
||||
pkg/
|
||||
**/github.com/
|
||||
|
||||
# ignore edit files
|
||||
.*~
|
||||
.*.sw?
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[codz]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
# Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
# poetry.lock
|
||||
# poetry.toml
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
||||
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
||||
# pdm.lock
|
||||
# pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# pixi
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
||||
# pixi.lock
|
||||
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
||||
# in the .venv directory. It is recommended not to include this directory in version control.
|
||||
.pixi
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# Redis
|
||||
*.rdb
|
||||
*.aof
|
||||
*.pid
|
||||
|
||||
# RabbitMQ
|
||||
mnesia/
|
||||
rabbitmq/
|
||||
rabbitmq-data/
|
||||
|
||||
# ActiveMQ
|
||||
activemq-data/
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.envrc
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
# .idea/
|
||||
|
||||
# Abstra
|
||||
# Abstra is an AI-powered process automation framework.
|
||||
# Ignore directories containing user credentials, local state, and settings.
|
||||
# Learn more at https://abstra.io/docs
|
||||
.abstra/
|
||||
|
||||
# Visual Studio Code
|
||||
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
||||
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
||||
# you could uncomment the following to ignore the entire vscode folder
|
||||
# .vscode/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Marimo
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# Streamlit
|
||||
.streamlit/secrets.toml
|
||||
|
||||
node_modules
|
||||
build
|
||||
npm-debug.log
|
||||
.env
|
||||
.DS_Store
|
||||
232
DEVELOPMENT.md
Normal file
232
DEVELOPMENT.md
Normal file
@ -0,0 +1,232 @@
|
||||
# 🚀 Разработка и тестирование всей системы
|
||||
|
||||
## 📋 Что запускаем:
|
||||
1. **Backend** (Go) - API сервер на порту 8080
|
||||
2. **Frontend** (React) - Веб-приложение на порту 5173
|
||||
3. **Bot** (Python) - Telegram бот
|
||||
|
||||
## 🔧 Предварительные настройки
|
||||
|
||||
### 1. Backend
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
# Настройте .env:
|
||||
# - DATABASE_URL=postgres://user:password@localhost:5432/quiz_app?sslmode=disable
|
||||
# - REDIS_URL=redis://localhost:6379/0
|
||||
# - SECRET_KEY=your-very-secret-key
|
||||
# - PORT=8080
|
||||
```
|
||||
|
||||
### 2. Frontend
|
||||
```bash
|
||||
cd frontend
|
||||
cp .env.example .env
|
||||
# Настройте .env:
|
||||
# - VITE_API_BASE_URL=http://localhost:8080/api
|
||||
```
|
||||
|
||||
### 3. Bot
|
||||
```bash
|
||||
cd bot
|
||||
cp .env.example .env
|
||||
# Настройте .env:
|
||||
# - BOT_TOKEN=ваш_токен_бота
|
||||
# - BACKEND_API_URL=http://localhost:8080
|
||||
# - FRONTEND_URL=http://localhost:5173
|
||||
# - BOT_USERNAME=ваш_бот_юзернейм
|
||||
# - ADMIN_USER_IDS=ваш_telegram_id
|
||||
```
|
||||
|
||||
## 🚀 Запуск системы
|
||||
|
||||
### Вариант 1: Ручной запуск (рекомендуется для разработки)
|
||||
|
||||
Откройте 3 отдельных терминала:
|
||||
|
||||
**Терминал 1 - Backend:**
|
||||
```bash
|
||||
cd backend
|
||||
go run cmd/server/main.go
|
||||
```
|
||||
Должен увидеть: `Server running on port 8080`
|
||||
|
||||
**Терминал 2 - Frontend:**
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
Должен увидеть: `Local: http://localhost:5173/`
|
||||
|
||||
**Терминал 3 - Bot:**
|
||||
```bash
|
||||
cd bot
|
||||
pip install -r requirements.txt
|
||||
python bot.py
|
||||
```
|
||||
Должен увидеть: `Starting bot @YourBotUsername`
|
||||
|
||||
### Вариант 2: Одновременный запуск (с помощью tmux)
|
||||
|
||||
```bash
|
||||
# Создайте новую tmux сессию
|
||||
tmux new-session -s quiz-app
|
||||
|
||||
# Разделите окно на 3 панели
|
||||
tmux split-window -h
|
||||
tmux split-window -v
|
||||
|
||||
# В каждой панели запустите свой компонент
|
||||
# Верхняя левая: Backend
|
||||
cd backend && go run cmd/server/main.go
|
||||
|
||||
# Верхняя правая: Frontend
|
||||
cd frontend && pnpm dev
|
||||
|
||||
# Нижняя: Bot
|
||||
cd bot && python bot.py
|
||||
|
||||
# Переключение между панелями: Ctrl+B + стрелки
|
||||
# Отключиться от сессии: Ctrl+B + D
|
||||
# Подключиться обратно: tmux attach -t quiz-app
|
||||
```
|
||||
|
||||
### Вариант 3: С помощью Docker Compose (если настроено)
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## 🧪 Тестирование
|
||||
|
||||
### 1. Проверка Backend API
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
# Должен вернуть статус 200
|
||||
```
|
||||
|
||||
### 2. Проверка Frontend
|
||||
Откройте в браузере: http://localhost:5173
|
||||
|
||||
### 3. Проверка Bot
|
||||
Найдите вашего бота в Telegram: @YourBotUsername
|
||||
Отправьте команду `/start`
|
||||
|
||||
## 🎫 Тестирование QR-кодов
|
||||
|
||||
### Шаг 1: Создайте тестового администратора
|
||||
1. Получите ваш Telegram User ID: `@userinfobot`
|
||||
2. Добавьте его в `bot/.env`: `ADMIN_USER_IDS=ваш_id`
|
||||
|
||||
### Шаг 2: Сгенерируйте QR-коды через бота
|
||||
```
|
||||
/admin
|
||||
→ Генерировать QR-коды
|
||||
→ Выберите тип: reward
|
||||
→ Введите сумму: 50
|
||||
→ Получите токены
|
||||
```
|
||||
|
||||
### Шаг 3: Протестируйте сканирование
|
||||
1. Откройте фронтенд: http://localhost:5173
|
||||
2. Перейдите на страницу QR-сканера
|
||||
3. Создайте QR-код с одним из токенов
|
||||
4. Отсканируйте его через приложение
|
||||
|
||||
### Шаг 4: Проверьте интеграцию
|
||||
- QR-код должен быть успешно отсканирован
|
||||
- Должны появиться звезды на балансе
|
||||
- В backend должны появиться записи о сканировании
|
||||
|
||||
## 🔍 Отладка
|
||||
|
||||
### Проверьте логи:
|
||||
```bash
|
||||
# Backend логи (в терминале)
|
||||
# Tail логи если запущено как сервис
|
||||
tail -f /var/log/quiz-app/backend.log
|
||||
|
||||
# Frontend логи (в консоли браузера)
|
||||
# Откройте DevTools → Console
|
||||
|
||||
# Bot логи (в терминале)
|
||||
```
|
||||
|
||||
### Общие проблемы:
|
||||
1. **Порт занят**: `lsof -i :8080` и `kill -9 PID`
|
||||
2. **Backend не запущен**: Frontend не сможет подключиться к API
|
||||
3. **Bot не запущен**: Telegram команды не будут работать
|
||||
4. **База данных**: Убедитесь, что PostgreSQL и Redis запущены
|
||||
|
||||
## 📱 Тестирование на мобильном
|
||||
|
||||
### Доступ к локальному серверу с телефона:
|
||||
1. **Wi-Fi сеть**: телефон и компьютер должны быть в одной сети
|
||||
2. **IP адрес**: `ifconfig` или `ipconfig` чтобы узнать IP
|
||||
3. **Настройте .env файлы**:
|
||||
```
|
||||
# Frontend .env
|
||||
VITE_API_BASE_URL=http://192.168.1.100:8080/api
|
||||
|
||||
# Bot .env
|
||||
BACKEND_API_URL=http://192.168.1.100:8080
|
||||
FRONTEND_URL=http://192.168.1.100:5173
|
||||
```
|
||||
|
||||
### Telegram Web App:
|
||||
1. Откройте бота в Telegram
|
||||
2. Нажмите на кнопку "Открыть Викторины"
|
||||
3. Должно открыться веб-приложение
|
||||
|
||||
## ✅ Проверочный чеклист
|
||||
|
||||
- [ ] Backend запущен на порту 8080
|
||||
- [ ] Frontend запущен на порту 5173
|
||||
- [ ] Bot запущен и отвечает на команды
|
||||
- [ ] База данных PostgreSQL работает
|
||||
- [ ] Redis работает
|
||||
- [ ] QR-коды генерируются через бота
|
||||
- [ ] QR-коды сканируются через фронтенд
|
||||
- [ ] Баллы начисляются за сканирование
|
||||
- [ ] Викторины работают корректно
|
||||
- [ ] Магазин призов функционирует
|
||||
|
||||
## 🛑 Остановка
|
||||
|
||||
### Ручная остановка:
|
||||
```bash
|
||||
# В каждом терминале нажмите Ctrl+C
|
||||
```
|
||||
|
||||
### Через tmux:
|
||||
```bash
|
||||
tmux kill-session -t quiz-app
|
||||
```
|
||||
|
||||
### Через Docker:
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
## 🚨 Частые проблемы
|
||||
|
||||
### Backend не стартует:
|
||||
- Проверьте PostgreSQL и Redis: `brew services list`
|
||||
- Проверьте порты: `lsof -i :8080`
|
||||
- Проверьте .env файл
|
||||
|
||||
### Frontend не видит API:
|
||||
- Проверьте VITE_API_BASE_URL в .env
|
||||
- Убедитесь, что backend запущен
|
||||
- Проверьте CORS настройки в backend
|
||||
|
||||
### Bot не работает:
|
||||
- Проверьте BOT_TOKEN
|
||||
- Убедитесь, что бот не заблокирован
|
||||
- Проверьте права доступа к API
|
||||
|
||||
### QR-коды не сканируются:
|
||||
- Проверьте права доступа к камере
|
||||
- Убедитесь, что backend API доступен
|
||||
- Проверьте формат токенов
|
||||
11
backend/.env.example
Normal file
11
backend/.env.example
Normal file
@ -0,0 +1,11 @@
|
||||
# Server port
|
||||
PORT=8080
|
||||
|
||||
# PostgreSQL connection string
|
||||
DATABASE_URL=postgres://user:password@localhost:5432/quiz_app?sslmode=disable
|
||||
|
||||
# Redis connection string
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# Secret key for signing JWT or validating Telegram data
|
||||
SECRET_KEY=your-very-secret-key
|
||||
204
backend/1753683755292-30b3431f487b4cc1863e57a81d78e289.sh
Executable file
204
backend/1753683755292-30b3431f487b4cc1863e57a81d78e289.sh
Executable file
@ -0,0 +1,204 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ========================
|
||||
# 常量定义
|
||||
# ========================
|
||||
SCRIPT_NAME=$(basename "$0")
|
||||
NODE_MIN_VERSION=18
|
||||
NODE_INSTALL_VERSION=22
|
||||
NVM_VERSION="v0.40.3"
|
||||
CLAUDE_PACKAGE="@anthropic-ai/claude-code"
|
||||
CONFIG_DIR="$HOME/.claude"
|
||||
CONFIG_FILE="$CONFIG_DIR/settings.json"
|
||||
API_BASE_URL="https://api.z.ai/api/anthropic"
|
||||
API_KEY_URL="https://z.ai/manage-apikey/apikey-list"
|
||||
API_TIMEOUT_MS=3000000
|
||||
|
||||
# ========================
|
||||
# 工具函数
|
||||
# ========================
|
||||
|
||||
log_info() {
|
||||
echo "🔹 $*"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo "✅ $*"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo "❌ $*" >&2
|
||||
}
|
||||
|
||||
ensure_dir_exists() {
|
||||
local dir="$1"
|
||||
if [ ! -d "$dir" ]; then
|
||||
mkdir -p "$dir" || {
|
||||
log_error "Failed to create directory: $dir"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
}
|
||||
|
||||
# ========================
|
||||
# Node.js 安装函数
|
||||
# ========================
|
||||
|
||||
install_nodejs() {
|
||||
local platform=$(uname -s)
|
||||
|
||||
case "$platform" in
|
||||
Linux|Darwin)
|
||||
log_info "Installing Node.js on $platform..."
|
||||
|
||||
# 安装 nvm
|
||||
log_info "Installing nvm ($NVM_VERSION)..."
|
||||
curl -s https://raw.githubusercontent.com/nvm-sh/nvm/"$NVM_VERSION"/install.sh | bash
|
||||
|
||||
# 加载 nvm
|
||||
log_info "Loading nvm environment..."
|
||||
\. "$HOME/.nvm/nvm.sh"
|
||||
|
||||
# 安装 Node.js
|
||||
log_info "Installing Node.js $NODE_INSTALL_VERSION..."
|
||||
nvm install "$NODE_INSTALL_VERSION"
|
||||
|
||||
# 验证安装
|
||||
node -v &>/dev/null || {
|
||||
log_error "Node.js installation failed"
|
||||
exit 1
|
||||
}
|
||||
log_success "Node.js installed: $(node -v)"
|
||||
log_success "npm version: $(npm -v)"
|
||||
;;
|
||||
*)
|
||||
log_error "Unsupported platform: $platform"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ========================
|
||||
# Node.js 检查函数
|
||||
# ========================
|
||||
|
||||
check_nodejs() {
|
||||
if command -v node &>/dev/null; then
|
||||
current_version=$(node -v | sed 's/v//')
|
||||
major_version=$(echo "$current_version" | cut -d. -f1)
|
||||
|
||||
if [ "$major_version" -ge "$NODE_MIN_VERSION" ]; then
|
||||
log_success "Node.js is already installed: v$current_version"
|
||||
return 0
|
||||
else
|
||||
log_info "Node.js v$current_version is installed but version < $NODE_MIN_VERSION. Upgrading..."
|
||||
install_nodejs
|
||||
fi
|
||||
else
|
||||
log_info "Node.js not found. Installing..."
|
||||
install_nodejs
|
||||
fi
|
||||
}
|
||||
|
||||
# ========================
|
||||
# Claude Code 安装
|
||||
# ========================
|
||||
|
||||
install_claude_code() {
|
||||
if command -v claude &>/dev/null; then
|
||||
log_success "Claude Code is already installed: $(claude --version)"
|
||||
else
|
||||
log_info "Installing Claude Code..."
|
||||
npm install -g "$CLAUDE_PACKAGE" || {
|
||||
log_error "Failed to install claude-code"
|
||||
exit 1
|
||||
}
|
||||
log_success "Claude Code installed successfully"
|
||||
fi
|
||||
}
|
||||
|
||||
configure_claude_json(){
|
||||
node --eval '
|
||||
const os = require("os");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const homeDir = os.homedir();
|
||||
const filePath = path.join(homeDir, ".claude.json");
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
fs.writeFileSync(filePath, JSON.stringify({ ...content, hasCompletedOnboarding: true }, null, 2), "utf-8");
|
||||
} else {
|
||||
fs.writeFileSync(filePath, JSON.stringify({ hasCompletedOnboarding: true }, null, 2), "utf-8");
|
||||
}'
|
||||
}
|
||||
|
||||
# ========================
|
||||
# API Key 配置
|
||||
# ========================
|
||||
|
||||
configure_claude() {
|
||||
log_info "Configuring Claude Code..."
|
||||
echo " You can get your API key from: $API_KEY_URL"
|
||||
read -s -p "🔑 Please enter your ZHIPU API key: " api_key
|
||||
echo
|
||||
|
||||
if [ -z "$api_key" ]; then
|
||||
log_error "API key cannot be empty. Please run the script again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ensure_dir_exists "$CONFIG_DIR"
|
||||
|
||||
# 写入配置文件
|
||||
node --eval '
|
||||
const os = require("os");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const homeDir = os.homedir();
|
||||
const filePath = path.join(homeDir, ".claude", "settings.json");
|
||||
const apiKey = "'"$api_key"'";
|
||||
|
||||
const content = fs.existsSync(filePath)
|
||||
? JSON.parse(fs.readFileSync(filePath, "utf-8"))
|
||||
: {};
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify({
|
||||
...content,
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey,
|
||||
ANTHROPIC_BASE_URL: "'"$API_BASE_URL"'",
|
||||
API_TIMEOUT_MS: "'"$API_TIMEOUT_MS"'",
|
||||
}
|
||||
}, null, 2), "utf-8");
|
||||
' || {
|
||||
log_error "Failed to write settings.json"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log_success "Claude Code configured successfully"
|
||||
}
|
||||
|
||||
# ========================
|
||||
# 主流程
|
||||
# ========================
|
||||
|
||||
main() {
|
||||
echo "🚀 Starting $SCRIPT_NAME"
|
||||
|
||||
check_nodejs
|
||||
install_claude_code
|
||||
configure_claude_json
|
||||
configure_claude
|
||||
|
||||
echo ""
|
||||
log_success "🎉 Installation completed successfully!"
|
||||
echo ""
|
||||
echo "🚀 You can now start using Claude Code with:"
|
||||
echo " claude"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
209
backend/AUTH_GUIDE.md
Normal file
209
backend/AUTH_GUIDE.md
Normal file
@ -0,0 +1,209 @@
|
||||
# Telegram WebApp Authentication Guide
|
||||
|
||||
## 📋 Обзор
|
||||
|
||||
Система аутентификации использует Telegram WebApp initData для безопасной верификации пользователей.
|
||||
|
||||
## 🔧 Как это работает
|
||||
|
||||
1. **Frontend** получает `initData` от Telegram WebApp
|
||||
2. **Backend** валидирует HMAC-SHA256 подпись
|
||||
3. **Success**: Пользователь аутентифицирован, данные доступны в handlers
|
||||
4. **Error**: Доступ запрещен (401 Unauthorized)
|
||||
|
||||
## 📝 Требования к переменным окружения
|
||||
|
||||
Добавьте в `.env` файл:
|
||||
|
||||
```env
|
||||
BOT_TOKEN=ваш_токен_бота_от_@BotFather
|
||||
```
|
||||
|
||||
## 🛡️ Middleware
|
||||
|
||||
### Auth Middleware
|
||||
```go
|
||||
// Применяется ко всем защищенным маршрутам
|
||||
app.Use(middleware.AuthMiddleware(middleware.AuthConfig{
|
||||
BotToken: cfg.BotToken,
|
||||
}))
|
||||
```
|
||||
|
||||
### Получение данных пользователя в handler
|
||||
```go
|
||||
userData := middleware.GetTelegramUser(c)
|
||||
if userData == nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(...)
|
||||
}
|
||||
|
||||
// Используем userData.ID, userData.FirstName и т.д.
|
||||
```
|
||||
|
||||
## 📡 API Эндпоинты
|
||||
|
||||
### POST /api/auth/validate
|
||||
Валидация initData без middleware (для инициализации)
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/auth/validate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"initData": "query_id=AAHdF6IQAAAAAN0XohDhrOrc&user=%7B%22id%22%3AYOUR_TELEGRAM_USER_ID%2C%22first_name%22%3A%22YOUR_FIRST_NAME%22%2C%22last_name%22%3A%22YOUR_LAST_NAME%22%2C%22username%22%3A%22YOUR_USERNAME%22%2C%22language_code%22%3A%22en%22%2C%22is_premium%22%3Atrue%2C%22allows_write_to_pm%22%3Atrue%7D&auth_date=1634567890&hash=YOUR_HASH_HERE..."
|
||||
}'
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Authentication successful",
|
||||
"data": {
|
||||
"id": YOUR_TELEGRAM_USER_ID,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"username": "johndoe",
|
||||
"photo_url": "https://t.me/i/userpic/320/johndoe.jpg",
|
||||
"auth_date": YOUR_AUTH_DATE,
|
||||
"hash": "abcd1234..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/auth/me
|
||||
Получение данных текущего аутентифицированного пользователя
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
curl -X GET http://localhost:8080/api/auth/me \
|
||||
-H "X-Telegram-WebApp-Init-Data: ваш_init_data_здесь"
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "User data retrieved successfully",
|
||||
"data": {
|
||||
"id": YOUR_TELEGRAM_USER_ID,
|
||||
"first_name": "John",
|
||||
"last_name": "Doe",
|
||||
"username": "johndoe",
|
||||
"photo_url": "https://t.me/i/userpic/320/johndoe.jpg",
|
||||
"auth_date": YOUR_AUTH_DATE,
|
||||
"hash": "abcd1234..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🔒 Защищенные эндпоинты
|
||||
|
||||
Все следующие эндпоинты требуют аутентификации:
|
||||
|
||||
### User Routes
|
||||
- `GET /api/me` - профиль пользователя
|
||||
- `GET /api/user/transactions` - история транзакций
|
||||
- `GET /api/user/purchases` - история покупок
|
||||
|
||||
### Quiz Routes
|
||||
- `GET /api/quizzes` - список викторин
|
||||
- `GET /api/quizzes/:id` - детали викторины
|
||||
- `POST /api/quizzes/:id/submit` - отправка ответов
|
||||
- `GET /api/quizzes/:id/can-repeat` - проверка возможности повтора
|
||||
|
||||
### Reward Routes
|
||||
- `GET /api/rewards` - список призов
|
||||
- `POST /api/rewards/:id/purchase` - покупка приза
|
||||
|
||||
### QR Routes
|
||||
- `POST /api/qr/validate` - валидация QR кода
|
||||
|
||||
## 🔄 Как использовать в фронтенде
|
||||
|
||||
### 1. Получение initData из Telegram WebApp
|
||||
```javascript
|
||||
// В вашем Telegram Mini App
|
||||
const initData = window.Telegram.WebApp.initData;
|
||||
```
|
||||
|
||||
### 2. Отправка на бэкенд
|
||||
```javascript
|
||||
// Вариант 1: Через заголовок (рекомендуется)
|
||||
const response = await fetch('/api/qr/validate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Telegram-WebApp-Init-Data': initData
|
||||
},
|
||||
body: JSON.stringify({ payload: 'qr_token_here' })
|
||||
});
|
||||
|
||||
// Вариант 2: Через body для /api/auth/validate
|
||||
const response = await fetch('/api/auth/validate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ initData })
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Обработка ошибок
|
||||
```javascript
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
// Переадресация на страницу аутентификации
|
||||
window.location.href = '/auth';
|
||||
} else {
|
||||
// Другая ошибка
|
||||
console.error('Request failed:', response.statusText);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## ⚠️ Важные моменты
|
||||
|
||||
1. **Время жизни**: initData действителен 5 минут
|
||||
2. **Безопасность**: Всегда проверяйте HMAC подпись на бэкенде
|
||||
3. **Bot Token**: Храните в секрете, никогда в клиентском коде
|
||||
4. **User ID**: Используйте `userData.ID` как уникальный идентификатор
|
||||
|
||||
## 🔧 Тестирование
|
||||
|
||||
### Генерация тестового initData
|
||||
Используйте [Telegram WebApp Tools](https://telegram-web-app-tools.vercel.app/) для генерации тестовых initData.
|
||||
|
||||
### Пример curl с реальными данными
|
||||
```bash
|
||||
# Замените YOUR_INIT_DATA на реальные данные
|
||||
curl -X POST http://localhost:8080/api/qr/validate \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Telegram-WebApp-Init-Data: YOUR_INIT_DATA" \
|
||||
-d '{"payload": "test_qr_token"}'
|
||||
```
|
||||
|
||||
## 🚀 Production Deployment
|
||||
|
||||
1. **HTTPS**: Обязательно используйте HTTPS в production
|
||||
2. **Bot Token**: Используйте переменные окружения
|
||||
3. **Rate Limiting**: Добавьте rate limiting для auth эндпоинтов
|
||||
4. **Logging**: Логируйте неудачные попытки аутентификации
|
||||
5. **Security**: Регулярно обновляйте зависимости
|
||||
|
||||
## 🔍 Отладка
|
||||
|
||||
### Распространенные ошибки
|
||||
|
||||
1. **401 Unauthorized**: Неверный initData или просрочен
|
||||
2. **Hash verification failed**: Неверный bot token или поврежденные данные
|
||||
3. **Missing Telegram init data**: Отсутствует заголовок или initData
|
||||
4. **Auth data expired**: Данные старше 5 минут
|
||||
|
||||
### Debug режим
|
||||
Для отладки можно временно отключить проверку времени:
|
||||
```go
|
||||
// В middleware/auth.go закомментируйте проверку времени
|
||||
// if time.Since(authTime) > 5*time.Minute {
|
||||
// return errors.New("auth data expired")
|
||||
// }
|
||||
```
|
||||
44
backend/Makefile
Normal file
44
backend/Makefile
Normal file
@ -0,0 +1,44 @@
|
||||
.PHONY: migrate-up migrate-down migrate-status goose-install run build clean
|
||||
|
||||
# Database connection string - update this to match your database
|
||||
DB_URL="postgres://user:password@localhost:5432/quiz_app?sslmode=disable"
|
||||
|
||||
# Install goose
|
||||
goose-install:
|
||||
go install github.com/pressly/goose/v3/cmd/goose@latest
|
||||
|
||||
# Run all pending migrations
|
||||
migrate-up:
|
||||
goose -dir=migrations postgres "$(DB_URL)" up
|
||||
|
||||
# Rollback the last migration
|
||||
migrate-down:
|
||||
goose -dir=migrations postgres "$(DB_URL)" down
|
||||
|
||||
# Show migration status
|
||||
migrate-status:
|
||||
goose -dir=migrations postgres "$(DB_URL)" status
|
||||
|
||||
# Create a new migration file
|
||||
create-migration:
|
||||
@if [ -z "$(NAME)" ]; then \
|
||||
echo "Usage: make create-migration NAME=name_of_migration"; \
|
||||
exit 1; \
|
||||
fi
|
||||
goose -dir=migrations create $(NAME) sql
|
||||
|
||||
# Run the application
|
||||
run:
|
||||
go run cmd/server/main.go
|
||||
|
||||
# Build the application
|
||||
build:
|
||||
go build -o bin/server cmd/server/main.go
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf bin/
|
||||
|
||||
# Development setup
|
||||
dev-setup: goose-install migrate-up
|
||||
@echo "Development setup complete!"
|
||||
304
backend/QR_API_EXAMPLES.md
Normal file
304
backend/QR_API_EXAMPLES.md
Normal file
@ -0,0 +1,304 @@
|
||||
# QR API Endpoints - Примеры запросов
|
||||
|
||||
## 1. Генерация QR кодов (Admin endpoint)
|
||||
|
||||
### POST /api/admin/qrcodes
|
||||
|
||||
Генерирует уникальные QR токены указанного типа.
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/admin/qrcodes \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "reward",
|
||||
"value": "50",
|
||||
"count": 5
|
||||
}'
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "5 unique QR codes generated successfully",
|
||||
"data": {
|
||||
"tokens": [
|
||||
"a1b2c3d4e5f678901234567890abcdef",
|
||||
"b2c3d4e5f678901234567890abcdef12",
|
||||
"c3d4e5f678901234567890abcdef1234",
|
||||
"d4e5f678901234567890abcdef123456",
|
||||
"e5f678901234567890abcdef12345678"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Генерация QR для викторины
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/admin/qrcodes \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "quiz",
|
||||
"value": "123",
|
||||
"count": 3
|
||||
}'
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "3 unique QR codes generated successfully",
|
||||
"data": {
|
||||
"tokens": [
|
||||
"f678901234567890abcdef1234567890a",
|
||||
"78901234567890abcdef1234567890ab",
|
||||
"8901234567890abcdef1234567890abc"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Генерация QR для магазина
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/admin/qrcodes \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "shop",
|
||||
"value": "discount_10",
|
||||
"count": 2
|
||||
}'
|
||||
```
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "2 unique QR codes generated successfully",
|
||||
"data": {
|
||||
"tokens": [
|
||||
"901234567890abcdef1234567890abcd",
|
||||
"01234567890abcdef1234567890bcde"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Валидация QR кодов (User endpoint)
|
||||
|
||||
### POST /api/qr/validate
|
||||
|
||||
Валидирует уникальный токен и выполняет соответствующее действие.
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/qr/validate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"payload": "a1b2c3d4e5f678901234567890abcdef"
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Успешная валидация reward токена
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "QR payload validated successfully",
|
||||
"data": {
|
||||
"type": "REWARD",
|
||||
"data": {
|
||||
"amount": 50
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Успешная валидация quiz токена
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "QR payload validated successfully",
|
||||
"data": {
|
||||
"type": "OPEN_QUIZ",
|
||||
"data": {
|
||||
"id": 123,
|
||||
"title": "Знаешь ли ты бренд?",
|
||||
"description": "Тест на знание популярных брендов",
|
||||
"reward_stars": 100,
|
||||
"has_timer": false,
|
||||
"can_repeat": true,
|
||||
"repeat_cooldown_hours": 24,
|
||||
"questions": [
|
||||
{
|
||||
"id": 1,
|
||||
"text": "Какой бренд использует слоган 'Just Do It'?",
|
||||
"type": "single",
|
||||
"options": [
|
||||
{"id": 1, "text": "Adidas", "is_correct": false},
|
||||
{"id": 2, "text": "Nike", "is_correct": true},
|
||||
{"id": 3, "text": "Puma", "is_correct": false}
|
||||
],
|
||||
"order_index": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Успешная валидация shop токена
|
||||
|
||||
**Response (200 OK):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "QR payload validated successfully",
|
||||
"data": {
|
||||
"type": "SHOP_ACTION",
|
||||
"data": {
|
||||
"action": "discount_10"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Ошибки валидации
|
||||
|
||||
### Неверный или просроченный токен
|
||||
|
||||
**Response (400 Bad Request):**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "invalid or expired token"
|
||||
}
|
||||
```
|
||||
|
||||
### Токен уже использован
|
||||
|
||||
**Response (400 Bad Request):**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "token has already been used"
|
||||
}
|
||||
```
|
||||
|
||||
### Пустой payload
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/qr/validate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"payload": ""
|
||||
}'
|
||||
```
|
||||
|
||||
**Response (400 Bad Request):**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "Payload cannot be empty"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Ошибки генерации QR кодов
|
||||
|
||||
### Неверный тип
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/admin/qrcodes \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "invalid_type",
|
||||
"value": "50",
|
||||
"count": 1
|
||||
}'
|
||||
```
|
||||
|
||||
**Response (400 Bad Request):**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "Invalid type. Must be 'reward', 'quiz', or 'shop'"
|
||||
}
|
||||
```
|
||||
|
||||
### Неверное количество
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/admin/qrcodes \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "reward",
|
||||
"value": "50",
|
||||
"count": 0
|
||||
}'
|
||||
```
|
||||
|
||||
**Response (400 Bad Request):**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "Count must be between 1 and 100"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Как использовать сгенерированные токены
|
||||
|
||||
1. **Сгенерируйте токены** через админский эндпоинт
|
||||
2. **Создайте QR коды** с полученными токенами (используйте любой QR генератор)
|
||||
3. **Разместите QR коды** в физических локациях или в цифровых материалах
|
||||
4. **Пользователи сканируют** QR коды через приложение
|
||||
5. **Приложение отправляет** токен на `/api/qr/validate`
|
||||
6. **Система выполняет** действие (начисляет звезды, открывает викторину и т.д.)
|
||||
|
||||
### Пример QR кода для награды:
|
||||
```
|
||||
Содержимое QR кода: a1b2c3d4e5f678901234567890abcdef
|
||||
```
|
||||
|
||||
### Пример QR кода для викторины:
|
||||
```
|
||||
Содержимое QR кода: f678901234567890abcdef1234567890a
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Технические особенности
|
||||
|
||||
- **Токены** имеют 128-битную энтропию (32 шестнадцатеричных символа)
|
||||
- **Хранятся** в Redis с 30-дневным сроком действия
|
||||
- **Одноразовые** - после использования помечаются как использованные
|
||||
- **Типы:** reward (награда), quiz (викторина), shop (магазин)
|
||||
- **Валидация** проверяет существование, срок действия и факт использования
|
||||
94
backend/cmd/server/main.go
Normal file
94
backend/cmd/server/main.go
Normal file
@ -0,0 +1,94 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "sno/docs" // docs is generated by Swag CLI
|
||||
|
||||
"log"
|
||||
"sno/internal/config"
|
||||
"sno/internal/database"
|
||||
"sno/internal/handlers"
|
||||
"sno/internal/repository"
|
||||
"sno/internal/routes"
|
||||
"sno/internal/service"
|
||||
"sno/internal/redis"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
fiberSwagger "github.com/swaggo/fiber-swagger"
|
||||
)
|
||||
|
||||
// @title Telegram Quiz Mini App API
|
||||
// @version 1.0
|
||||
// @description API для Telegram Mini App с викторинами, QR-сканированием и внутренней валютой
|
||||
|
||||
// @host localhost:8080
|
||||
// @BasePath /
|
||||
|
||||
// @securityDefinitions.apikey ApiKeyAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
|
||||
// @securityDefinitions.basic BasicAuth
|
||||
|
||||
func main() {
|
||||
// 1. Load configuration
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load configuration: %v", err)
|
||||
}
|
||||
|
||||
// 2. Connect to the database
|
||||
dbPool, err := database.Connect(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
|
||||
// Connect to Redis
|
||||
redisClient, err := redis.Connect(cfg.RedisURL)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to Redis: %v", err)
|
||||
}
|
||||
|
||||
// 3. Initialize layers
|
||||
// Repositories
|
||||
quizRepo := repository.NewQuizRepository(dbPool)
|
||||
questionRepo := repository.NewQuestionRepository(dbPool)
|
||||
userRepo := repository.NewUserRepository(dbPool)
|
||||
quizAttemptRepo := repository.NewQuizAttemptRepository(dbPool)
|
||||
rewardRepo := repository.NewRewardRepository(dbPool)
|
||||
purchaseRepo := repository.NewPurchaseRepository(dbPool)
|
||||
qrScanRepo := repository.NewQRScanRepository(dbPool)
|
||||
adminRepo := repository.NewAdminRepository(dbPool)
|
||||
|
||||
// Services
|
||||
userService := service.NewUserService(userRepo, purchaseRepo, quizAttemptRepo)
|
||||
quizService := service.NewQuizService(dbPool, quizRepo, questionRepo, userRepo, userService, quizAttemptRepo)
|
||||
questionService := service.NewQuestionService(questionRepo)
|
||||
rewardService := service.NewRewardService(dbPool, rewardRepo, userRepo, purchaseRepo)
|
||||
adminService := service.NewAdminService(userRepo, adminRepo, redisClient)
|
||||
qrService := service.NewQRService(qrScanRepo, adminService, quizService, redisClient)
|
||||
|
||||
// Handlers
|
||||
quizHandler := handlers.NewQuizHandler(quizService, userService)
|
||||
questionHandler := handlers.NewQuestionHandler(questionService)
|
||||
rewardHandler := handlers.NewRewardHandler(rewardService)
|
||||
userHandler := handlers.NewUserHandler(userService)
|
||||
adminHandler := handlers.NewAdminHandler(adminService, qrService)
|
||||
qrHandler := handlers.NewQRHandler(qrService)
|
||||
authHandler := handlers.NewAuthHandler(cfg.BotToken, userService, adminService)
|
||||
|
||||
// 4. Create a new Fiber app
|
||||
app := fiber.New()
|
||||
|
||||
// Swagger documentation
|
||||
app.Get("/swagger/*", fiberSwagger.WrapHandler)
|
||||
|
||||
// 5. Setup routes
|
||||
routes.Setup(app, quizHandler, questionHandler, rewardHandler, userHandler, adminHandler, qrHandler, authHandler, adminService, cfg.BotToken)
|
||||
|
||||
// 6. Start the server
|
||||
log.Printf("Server is starting on port %s...", cfg.Port)
|
||||
err = app.Listen(":" + cfg.Port)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}
|
||||
29
backend/docker-compose.yml
Normal file
29
backend/docker-compose.yml
Normal file
@ -0,0 +1,29 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14-alpine
|
||||
container_name: quiz_app_db
|
||||
environment:
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: quiz_app
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
# - ./migrations:/docker-entrypoint-initdb.d # This line runs migrations on init
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: quiz_app_redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
2100
backend/docs/docs.go
Normal file
2100
backend/docs/docs.go
Normal file
File diff suppressed because it is too large
Load Diff
2076
backend/docs/swagger.json
Normal file
2076
backend/docs/swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
1327
backend/docs/swagger.yaml
Normal file
1327
backend/docs/swagger.yaml
Normal file
File diff suppressed because it is too large
Load Diff
61
backend/go.mod
Normal file
61
backend/go.mod
Normal file
@ -0,0 +1,61 @@
|
||||
module sno
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.7
|
||||
|
||||
require (
|
||||
github.com/gofiber/fiber/v2 v2.52.9
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/kelseyhightower/envconfig v1.4.0
|
||||
github.com/pressly/goose/v3 v3.15.0
|
||||
github.com/redis/go-redis/v9 v9.14.0
|
||||
github.com/swaggo/fiber-swagger v1.3.0
|
||||
golang.org/x/exp v0.0.0-20250911091902-df9299821621
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.1 // indirect
|
||||
github.com/go-openapi/spec v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.24.1 // indirect
|
||||
github.com/go-openapi/swag/cmdutils v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/conv v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/fileutils v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/loading v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/mangling v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/netutils v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.24.0 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.24.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/swaggo/files v1.0.1 // indirect
|
||||
github.com/swaggo/swag v1.16.6 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.66.0 // indirect
|
||||
golang.org/x/crypto v0.42.0 // indirect
|
||||
golang.org/x/mod v0.28.0 // indirect
|
||||
golang.org/x/net v0.44.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
golang.org/x/tools v0.37.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
224
backend/go.sum
Normal file
224
backend/go.sum
Normal file
@ -0,0 +1,224 @@
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/agiledragon/gomonkey/v2 v2.3.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY=
|
||||
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.22.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM=
|
||||
github.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU=
|
||||
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
||||
github.com/go-openapi/jsonreference v0.21.1 h1:bSKrcl8819zKiOgxkbVNRUBIr6Wwj9KYrDbMjRs0cDA=
|
||||
github.com/go-openapi/jsonreference v0.21.1/go.mod h1:PWs8rO4xxTUqKGu+lEvvCxD5k2X7QYkKAepJyCmSTT8=
|
||||
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
|
||||
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
|
||||
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/go-openapi/swag v0.24.1 h1:DPdYTZKo6AQCRqzwr/kGkxJzHhpKxZ9i/oX0zag+MF8=
|
||||
github.com/go-openapi/swag v0.24.1/go.mod h1:sm8I3lCPlspsBBwUm1t5oZeWZS0s7m/A+Psg0ooRU0A=
|
||||
github.com/go-openapi/swag/cmdutils v0.24.0 h1:KlRCffHwXFI6E5MV9n8o8zBRElpY4uK4yWyAMWETo9I=
|
||||
github.com/go-openapi/swag/cmdutils v0.24.0/go.mod h1:uxib2FAeQMByyHomTlsP8h1TtPd54Msu2ZDU/H5Vuf8=
|
||||
github.com/go-openapi/swag/conv v0.24.0 h1:ejB9+7yogkWly6pnruRX45D1/6J+ZxRu92YFivx54ik=
|
||||
github.com/go-openapi/swag/conv v0.24.0/go.mod h1:jbn140mZd7EW2g8a8Y5bwm8/Wy1slLySQQ0ND6DPc2c=
|
||||
github.com/go-openapi/swag/fileutils v0.24.0 h1:U9pCpqp4RUytnD689Ek/N1d2N/a//XCeqoH508H5oak=
|
||||
github.com/go-openapi/swag/fileutils v0.24.0/go.mod h1:3SCrCSBHyP1/N+3oErQ1gP+OX1GV2QYFSnrTbzwli90=
|
||||
github.com/go-openapi/swag/jsonname v0.24.0 h1:2wKS9bgRV/xB8c62Qg16w4AUiIrqqiniJFtZGi3dg5k=
|
||||
github.com/go-openapi/swag/jsonname v0.24.0/go.mod h1:GXqrPzGJe611P7LG4QB9JKPtUZ7flE4DOVechNaDd7Q=
|
||||
github.com/go-openapi/swag/jsonutils v0.24.0 h1:F1vE1q4pg1xtO3HTyJYRmEuJ4jmIp2iZ30bzW5XgZts=
|
||||
github.com/go-openapi/swag/jsonutils v0.24.0/go.mod h1:vBowZtF5Z4DDApIoxcIVfR8v0l9oq5PpYRUuteVu6f0=
|
||||
github.com/go-openapi/swag/loading v0.24.0 h1:ln/fWTwJp2Zkj5DdaX4JPiddFC5CHQpvaBKycOlceYc=
|
||||
github.com/go-openapi/swag/loading v0.24.0/go.mod h1:gShCN4woKZYIxPxbfbyHgjXAhO61m88tmjy0lp/LkJk=
|
||||
github.com/go-openapi/swag/mangling v0.24.0 h1:PGOQpViCOUroIeak/Uj/sjGAq9LADS3mOyjznmHy2pk=
|
||||
github.com/go-openapi/swag/mangling v0.24.0/go.mod h1:Jm5Go9LHkycsz0wfoaBDkdc4CkpuSnIEf62brzyCbhc=
|
||||
github.com/go-openapi/swag/netutils v0.24.0 h1:Bz02HRjYv8046Ycg/w80q3g9QCWeIqTvlyOjQPDjD8w=
|
||||
github.com/go-openapi/swag/netutils v0.24.0/go.mod h1:WRgiHcYTnx+IqfMCtu0hy9oOaPR0HnPbmArSRN1SkZM=
|
||||
github.com/go-openapi/swag/stringutils v0.24.0 h1:i4Z/Jawf9EvXOLUbT97O0HbPUja18VdBxeadyAqS1FM=
|
||||
github.com/go-openapi/swag/stringutils v0.24.0/go.mod h1:5nUXB4xA0kw2df5PRipZDslPJgJut+NjL7D25zPZ/4w=
|
||||
github.com/go-openapi/swag/typeutils v0.24.0 h1:d3szEGzGDf4L2y1gYOSSLeK6h46F+zibnEas2Jm/wIw=
|
||||
github.com/go-openapi/swag/typeutils v0.24.0/go.mod h1:q8C3Kmk/vh2VhpCLaoR2MVWOGP8y7Jc8l82qCTd1DYI=
|
||||
github.com/go-openapi/swag/yamlutils v0.24.0 h1:bhw4894A7Iw6ne+639hsBNRHg9iZg/ISrOVr+sJGp4c=
|
||||
github.com/go-openapi/swag/yamlutils v0.24.0/go.mod h1:DpKv5aYuaGm/sULePoeiG8uwMpZSfReo1HR3Ik0yaG8=
|
||||
github.com/gofiber/fiber/v2 v2.32.0/go.mod h1:CMy5ZLiXkn6qwthrl03YMyW1NLfj0rhxz2LKl4t7ZTY=
|
||||
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
|
||||
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
|
||||
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
|
||||
github.com/klauspost/compress v1.15.0/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
|
||||
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U=
|
||||
github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE=
|
||||
github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs=
|
||||
github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo=
|
||||
github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
|
||||
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/swaggo/fiber-swagger v1.3.0 h1:RMjIVDleQodNVdKuu7GRs25Eq8RVXK7MwY9f5jbobNg=
|
||||
github.com/swaggo/fiber-swagger v1.3.0/go.mod h1:18MuDqBkYEiUmeM/cAAB8CI28Bi62d/mys39j1QqF9w=
|
||||
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.35.0/go.mod h1:t/G+3rLek+CyY9bnIE+YlMRddxVAAGjhxndDB4i4C0I=
|
||||
github.com/valyala/fasthttp v1.36.0/go.mod h1:t/G+3rLek+CyY9bnIE+YlMRddxVAAGjhxndDB4i4C0I=
|
||||
github.com/valyala/fasthttp v1.66.0 h1:M87A0Z7EayeyNaV6pfO3tUTUiYO0dZfEJnRGXTVNuyU=
|
||||
github.com/valyala/fasthttp v1.66.0/go.mod h1:Y4eC+zwoocmXSVCB1JmhNbYtS7tZPRI2ztPB72EVObs=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU=
|
||||
golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
28
backend/internal/config/config.go
Normal file
28
backend/internal/config/config.go
Normal file
@ -0,0 +1,28 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/kelseyhightower/envconfig"
|
||||
"log"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string `envconfig:"PORT" default:"8080"`
|
||||
DatabaseURL string `envconfig:"DATABASE_URL" required:"true"`
|
||||
RedisURL string `envconfig:"REDIS_URL" required:"true"`
|
||||
SecretKey string `envconfig:"SECRET_KEY" required:"true"`
|
||||
BotToken string `envconfig:"BOT_TOKEN" required:"true"`
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
// Load .env file first, if it exists
|
||||
_ = godotenv.Load()
|
||||
|
||||
var cfg Config
|
||||
if err := envconfig.Process("", &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Println("Configuration loaded successfully")
|
||||
return &cfg, nil
|
||||
}
|
||||
21
backend/internal/database/database.go
Normal file
21
backend/internal/database/database.go
Normal file
@ -0,0 +1,21 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"log"
|
||||
)
|
||||
|
||||
func Connect(databaseURL string) (*pgxpool.Pool, error) {
|
||||
pool, err := pgxpool.New(context.Background(), databaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := pool.Ping(context.Background()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Println("Database connected successfully")
|
||||
return pool, nil
|
||||
}
|
||||
250
backend/internal/handlers/admin_handler.go
Normal file
250
backend/internal/handlers/admin_handler.go
Normal file
@ -0,0 +1,250 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"fmt"
|
||||
"sno/internal/models"
|
||||
"sno/internal/service"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// AdminHandler handles HTTP requests for admin operations.
|
||||
type AdminHandler struct {
|
||||
adminService service.AdminService
|
||||
qrService service.QRService
|
||||
}
|
||||
|
||||
// NewAdminHandler creates a new instance of an admin handler.
|
||||
func NewAdminHandler(adminSvc service.AdminService, qrSvc service.QRService) *AdminHandler {
|
||||
return &AdminHandler{
|
||||
adminService: adminSvc,
|
||||
qrService: qrSvc,
|
||||
}
|
||||
}
|
||||
|
||||
// GrantStars handles the request to manually grant stars to a user.
|
||||
// @Summary Grant stars to user
|
||||
// @Description Manually grants stars to a user (admin only)
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.GrantStarsRequest true "Grant stars request"
|
||||
// @Success 200 {object} object{success=bool,message=string}
|
||||
// @Failure 400 {object} object{success=bool,message=string}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/admin/users/grant-stars [post]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *AdminHandler) GrantStars(c *fiber.Ctx) error {
|
||||
var req models.GrantStarsRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Cannot parse JSON",
|
||||
})
|
||||
}
|
||||
|
||||
if err := h.adminService.GrantStars(c.Context(), req.UserID, req.Amount); err != nil {
|
||||
log.Printf("ERROR: Failed to grant stars: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "Stars granted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateQRCodes handles the request to generate unique QR codes.
|
||||
// @Summary Generate QR codes
|
||||
// @Description Generates unique QR codes for rewards, quizzes, or shop items (admin/operator only)
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.GenerateQRCodesRequest true "QR codes generation request"
|
||||
// @Success 201 {object} object{success=bool,message=string,data=models.GenerateQRCodesResponse}
|
||||
// @Failure 400 {object} object{success=bool,message=string}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/admin/qrcodes [post]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *AdminHandler) GenerateQRCodes(c *fiber.Ctx) error {
|
||||
var req models.GenerateQRCodesRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Cannot parse JSON",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate request
|
||||
if req.Type == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Type cannot be empty",
|
||||
})
|
||||
}
|
||||
|
||||
if req.Value == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Value cannot be empty",
|
||||
})
|
||||
}
|
||||
|
||||
if req.Count <= 0 || req.Count > 100 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Count must be between 1 and 100",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate type
|
||||
validTypes := map[string]bool{
|
||||
"reward": true,
|
||||
"quiz": true,
|
||||
"shop": true,
|
||||
}
|
||||
if !validTypes[req.Type] {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Invalid type. Must be 'reward', 'quiz', or 'shop'",
|
||||
})
|
||||
}
|
||||
|
||||
tokens := make([]string, 0, req.Count)
|
||||
for i := 0; i < req.Count; i++ {
|
||||
token, err := h.qrService.GenerateUniqueToken(c.Context(), req.Type, req.Value)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to generate QR token: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Failed to generate QR codes",
|
||||
})
|
||||
}
|
||||
tokens = append(tokens, token)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(Response{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("%d unique QR codes generated successfully", len(tokens)),
|
||||
Data: models.GenerateQRCodesResponse{
|
||||
Tokens: tokens,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// CreateOperator handles the request to create a new operator
|
||||
// @Summary Create operator
|
||||
// @Description Creates a new operator account (admin only)
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.CreateOperatorRequest true "Create operator request"
|
||||
// @Success 201 {object} object{success=bool,message=string}
|
||||
// @Failure 400 {object} object{success=bool,message=string}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/admin/operators [post]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *AdminHandler) CreateOperator(c *fiber.Ctx) error {
|
||||
var req models.CreateOperatorRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Cannot parse JSON",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate request
|
||||
if req.TelegramID == 0 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Telegram ID cannot be empty",
|
||||
})
|
||||
}
|
||||
|
||||
if req.Name == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Name cannot be empty",
|
||||
})
|
||||
}
|
||||
|
||||
if err := h.adminService.CreateOperator(c.Context(), req.TelegramID, req.Name); err != nil {
|
||||
log.Printf("ERROR: Failed to create operator: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(Response{
|
||||
Success: true,
|
||||
Message: "Operator created successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteOperator handles the request to delete an operator
|
||||
// @Summary Delete operator
|
||||
// @Description Deletes an operator account (admin only)
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Operator Telegram ID"
|
||||
// @Success 200 {object} object{success=bool,message=string}
|
||||
// @Failure 400 {object} object{success=bool,message=string}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/admin/operators/{id} [delete]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *AdminHandler) DeleteOperator(c *fiber.Ctx) error {
|
||||
telegramID, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Invalid Telegram ID",
|
||||
})
|
||||
}
|
||||
|
||||
if err := h.adminService.DeleteOperator(c.Context(), telegramID); err != nil {
|
||||
log.Printf("ERROR: Failed to delete operator: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "Operator deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// GetAnalytics handles the request to get analytics data
|
||||
// @Summary Get analytics data
|
||||
// @Description Returns analytics data including user statistics, rewards, and quiz performance (admin/operator only)
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{success=bool,message=string,data=object}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/admin/analytics [get]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *AdminHandler) GetAnalytics(c *fiber.Ctx) error {
|
||||
analytics, err := h.adminService.GetAnalytics(c.Context())
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to get analytics: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Failed to retrieve analytics",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "Analytics retrieved successfully",
|
||||
Data: analytics,
|
||||
})
|
||||
}
|
||||
160
backend/internal/handlers/auth_handler.go
Normal file
160
backend/internal/handlers/auth_handler.go
Normal file
@ -0,0 +1,160 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"sno/internal/middleware"
|
||||
"sno/internal/service"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// AuthHandler handles authentication-related requests
|
||||
type AuthHandler struct {
|
||||
botToken string
|
||||
userService service.UserService
|
||||
adminService service.AdminService
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new auth handler
|
||||
func NewAuthHandler(botToken string, userService service.UserService, adminService service.AdminService) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
botToken: botToken,
|
||||
userService: userService,
|
||||
adminService: adminService,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate validates Telegram WebApp init data and creates/updates user
|
||||
// @Summary Validate Telegram WebApp init data
|
||||
// @Description Validates Telegram WebApp init data, creates/updates user, and returns user information
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body object{initData=string} true "Init data validation request"
|
||||
// @Success 200 {object} object{success=bool,message=string,data=object}
|
||||
// @Failure 400 {object} object{success=bool,message=string}
|
||||
// @Failure 401 {object} object{success=bool,message=string}
|
||||
// @Router /api/auth/validate [post]
|
||||
func (h *AuthHandler) Validate(c *fiber.Ctx) error {
|
||||
// Get initData from request body
|
||||
var request struct {
|
||||
InitData string `json:"initData"`
|
||||
}
|
||||
|
||||
if err := c.BodyParser(&request); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Cannot parse JSON",
|
||||
})
|
||||
}
|
||||
|
||||
if request.InitData == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Init data is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate the init data
|
||||
userData, err := middleware.ValidateTelegramInitData(request.InitData, h.botToken)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(Response{
|
||||
Success: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// Create or update user in database
|
||||
dbUser, err := h.userService.GetOrCreateUser(
|
||||
c.Context(),
|
||||
userData.ID,
|
||||
userData.FirstName,
|
||||
userData.LastName,
|
||||
userData.Username,
|
||||
userData.PhotoURL,
|
||||
)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Failed to create or update user",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "Authentication successful",
|
||||
Data: dbUser,
|
||||
})
|
||||
}
|
||||
|
||||
// GetMe returns current authenticated user info from database
|
||||
// @Summary Get current authenticated user
|
||||
// @Description Returns information about the currently authenticated user from database
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{success=bool,message=string,data=object}
|
||||
// @Failure 401 {object} object{success=bool,message=string}
|
||||
// @Failure 404 {object} object{success=bool,message=string}
|
||||
// @Router /api/auth/me [get]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
|
||||
userData := middleware.GetTelegramUser(c)
|
||||
if userData == nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(Response{
|
||||
Success: false,
|
||||
Message: "User not authenticated",
|
||||
})
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
dbUser, err := h.userService.GetUserProfile(c.Context(), userData.ID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(Response{
|
||||
Success: false,
|
||||
Message: "User not found in database",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "User data retrieved successfully",
|
||||
Data: dbUser,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAdminRole returns the admin role for the current user
|
||||
// @Summary Get admin role for current user
|
||||
// @Description Returns the admin role (admin, operator, or user) for the currently authenticated user
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{success=bool,message=string,data=object}
|
||||
// @Failure 401 {object} object{success=bool,message=string}
|
||||
// @Router /api/auth/admin-role [get]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *AuthHandler) GetAdminRole(c *fiber.Ctx) error {
|
||||
userData := middleware.GetTelegramUser(c)
|
||||
if userData == nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(Response{
|
||||
Success: false,
|
||||
Message: "User not authenticated",
|
||||
})
|
||||
}
|
||||
|
||||
// Check user role from admin service
|
||||
role, err := h.adminService.GetUserRole(c.Context(), userData.ID)
|
||||
if err != nil {
|
||||
// If user not found in admins table, they are a regular user
|
||||
role = "user"
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "Admin role retrieved successfully",
|
||||
Data: map[string]string{
|
||||
"role": string(role),
|
||||
},
|
||||
})
|
||||
}
|
||||
22
backend/internal/handlers/handlers.go
Normal file
22
backend/internal/handlers/handlers.go
Normal file
@ -0,0 +1,22 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// --- Helper for consistent responses ---
|
||||
type Response struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
func NotImplemented(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusNotImplemented).JSON(Response{
|
||||
Success: false,
|
||||
Message: "This endpoint is not yet implemented.",
|
||||
})
|
||||
}
|
||||
|
||||
// We will create separate files for each group of handlers later
|
||||
// e.g., quiz_handler.go, user_handler.go, etc.
|
||||
151
backend/internal/handlers/qr_handler.go
Normal file
151
backend/internal/handlers/qr_handler.go
Normal file
@ -0,0 +1,151 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sno/internal/middleware"
|
||||
"sno/internal/models"
|
||||
"sno/internal/service"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// QRHandler handles HTTP requests for QR code operations.
|
||||
type QRHandler struct {
|
||||
qrService service.QRService
|
||||
}
|
||||
|
||||
// NewQRHandler creates a new instance of a QR handler.
|
||||
func NewQRHandler(s service.QRService) *QRHandler {
|
||||
return &QRHandler{qrService: s}
|
||||
}
|
||||
|
||||
// Validate handles the request to validate a QR code payload.
|
||||
// @Summary Validate QR code payload
|
||||
// @Description Validates a QR code payload and processes the associated action
|
||||
// @Tags qr
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.QRValidateRequest true "QR validation request"
|
||||
// @Success 200 {object} object{success=bool,message=string,data=object}
|
||||
// @Failure 400 {object} object{success=bool,message=string}
|
||||
// @Failure 401 {object} object{success=bool,message=string}
|
||||
// @Router /api/qr/validate [post]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *QRHandler) Validate(c *fiber.Ctx) error {
|
||||
var req models.QRValidateRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Cannot parse JSON",
|
||||
})
|
||||
}
|
||||
|
||||
if req.Payload == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Payload cannot be empty",
|
||||
})
|
||||
}
|
||||
|
||||
// Get user data from auth middleware
|
||||
userData := middleware.GetTelegramUser(c)
|
||||
if userData == nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(Response{
|
||||
Success: false,
|
||||
Message: "User not authenticated",
|
||||
})
|
||||
}
|
||||
|
||||
resp, err := h.qrService.ValidatePayload(c.Context(), userData.ID, req.Payload)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to validate QR payload: %v", err)
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{ // Using 400 for invalid payloads
|
||||
Success: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "QR payload validated successfully",
|
||||
Data: resp,
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateQRCodes handles the request to generate unique QR codes
|
||||
// @Summary Generate QR codes
|
||||
// @Description Generates unique QR codes for rewards, quizzes, or shop items (admin/operator only)
|
||||
// @Tags qr
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body models.GenerateQRCodesRequest true "QR codes generation request"
|
||||
// @Success 200 {object} object{success=bool,message=string,data=models.GenerateQRCodesResponse}
|
||||
// @Failure 400 {object} object{success=bool,message=string}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/admin/qrcodes [post]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *QRHandler) GenerateQRCodes(c *fiber.Ctx) error {
|
||||
var req models.GenerateQRCodesRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Cannot parse JSON",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate request
|
||||
if req.Type == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Type cannot be empty",
|
||||
})
|
||||
}
|
||||
|
||||
if req.Value == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Value cannot be empty",
|
||||
})
|
||||
}
|
||||
|
||||
if req.Count <= 0 || req.Count > 100 {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Count must be between 1 and 100",
|
||||
})
|
||||
}
|
||||
|
||||
// Validate type
|
||||
validTypes := map[string]bool{
|
||||
"reward": true,
|
||||
"quiz": true,
|
||||
"shop": true,
|
||||
}
|
||||
if !validTypes[req.Type] {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Invalid type. Must be 'reward', 'quiz', or 'shop'",
|
||||
})
|
||||
}
|
||||
|
||||
tokens := make([]string, 0, req.Count)
|
||||
for i := 0; i < req.Count; i++ {
|
||||
token, err := h.qrService.GenerateUniqueToken(c.Context(), req.Type, req.Value)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to generate QR token: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Failed to generate QR codes",
|
||||
})
|
||||
}
|
||||
tokens = append(tokens, token)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "QR codes generated successfully",
|
||||
Data: models.GenerateQRCodesResponse{
|
||||
Tokens: tokens,
|
||||
},
|
||||
})
|
||||
}
|
||||
164
backend/internal/handlers/question_handler.go
Normal file
164
backend/internal/handlers/question_handler.go
Normal file
@ -0,0 +1,164 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sno/internal/models"
|
||||
"sno/internal/service"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// QuestionHandler handles HTTP requests for questions.
|
||||
type QuestionHandler struct {
|
||||
questionService service.QuestionService
|
||||
}
|
||||
|
||||
// NewQuestionHandler creates a new instance of a question handler.
|
||||
func NewQuestionHandler(questionService service.QuestionService) *QuestionHandler {
|
||||
return &QuestionHandler{questionService: questionService}
|
||||
}
|
||||
|
||||
// CreateQuestion handles the request to add a new question to a quiz.
|
||||
// @Summary Create question
|
||||
// @Description Adds a new question to a quiz (admin/operator only)
|
||||
// @Tags questions
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param quiz_id path int true "Quiz ID"
|
||||
// @Param question body models.Question true "Question object"
|
||||
// @Success 201 {object} object{success=bool,message=string,data=models.Question}
|
||||
// @Failure 400 {object} object{success=bool,message=string}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/admin/quizzes/{quiz_id}/questions [post]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *QuestionHandler) CreateQuestion(c *fiber.Ctx) error {
|
||||
quizID, err := strconv.Atoi(c.Params("quiz_id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Invalid quiz ID",
|
||||
})
|
||||
}
|
||||
|
||||
var question models.Question
|
||||
if err := c.BodyParser(&question); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Cannot parse JSON",
|
||||
})
|
||||
}
|
||||
|
||||
// Set the QuizID from the URL parameter
|
||||
question.QuizID = quizID
|
||||
|
||||
createdQuestion, err := h.questionService.CreateQuestion(c.Context(), &question)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to create question: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: err.Error(), // Return the actual validation error message
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(Response{
|
||||
Success: true,
|
||||
Message: "Question created successfully",
|
||||
Data: createdQuestion,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateQuestion handles the request to update an existing question.
|
||||
// @Summary Update question
|
||||
// @Description Updates an existing question (admin/operator only)
|
||||
// @Tags questions
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param quiz_id path int true "Quiz ID"
|
||||
// @Param question_id path int true "Question ID"
|
||||
// @Param question body models.Question true "Updated question object"
|
||||
// @Success 200 {object} object{success=bool,message=string,data=models.Question}
|
||||
// @Failure 400 {object} object{success=bool,message=string}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/admin/quizzes/{quiz_id}/questions/{question_id} [put]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *QuestionHandler) UpdateQuestion(c *fiber.Ctx) error {
|
||||
quizID, err := strconv.Atoi(c.Params("quiz_id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Invalid quiz ID",
|
||||
})
|
||||
}
|
||||
|
||||
questionID, err := strconv.Atoi(c.Params("question_id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Invalid question ID",
|
||||
})
|
||||
}
|
||||
|
||||
var question models.Question
|
||||
if err := c.BodyParser(&question); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Cannot parse JSON",
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure the question belongs to the specified quiz
|
||||
question.QuizID = quizID
|
||||
|
||||
updatedQuestion, err := h.questionService.UpdateQuestion(c.Context(), questionID, &question)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to update question: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "Question updated successfully",
|
||||
Data: updatedQuestion,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteQuestion handles the request to delete a question.
|
||||
// @Summary Delete question
|
||||
// @Description Deletes a question (admin/operator only)
|
||||
// @Tags questions
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param quiz_id path int true "Quiz ID"
|
||||
// @Param question_id path int true "Question ID"
|
||||
// @Success 200 {object} object{success=bool,message=string}
|
||||
// @Failure 400 {object} object{success=bool,message=string}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/admin/quizzes/{quiz_id}/questions/{question_id} [delete]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *QuestionHandler) DeleteQuestion(c *fiber.Ctx) error {
|
||||
questionID, err := strconv.Atoi(c.Params("question_id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Invalid question ID",
|
||||
})
|
||||
}
|
||||
|
||||
err = h.questionService.DeleteQuestion(c.Context(), questionID)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to delete question: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Failed to delete question",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "Question deleted successfully",
|
||||
})
|
||||
}
|
||||
326
backend/internal/handlers/quiz_handler.go
Normal file
326
backend/internal/handlers/quiz_handler.go
Normal file
@ -0,0 +1,326 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"sno/internal/middleware"
|
||||
"sno/internal/models"
|
||||
"sno/internal/service"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// QuizHandler handles HTTP requests for quizzes.
|
||||
type QuizHandler struct {
|
||||
quizService service.QuizService
|
||||
userService service.UserService
|
||||
}
|
||||
|
||||
// NewQuizHandler creates a new instance of a quiz handler.
|
||||
func NewQuizHandler(quizService service.QuizService, userService service.UserService) *QuizHandler {
|
||||
return &QuizHandler{quizService: quizService, userService: userService}
|
||||
}
|
||||
|
||||
// GetAllQuizzes handles the request to get all active quizzes.
|
||||
// @Summary Get all active quizzes
|
||||
// @Description Returns a list of all active quizzes
|
||||
// @Tags quizzes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{success=bool,message=string,data=[]models.Quiz}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/quizzes [get]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *QuizHandler) GetAllQuizzes(c *fiber.Ctx) error {
|
||||
quizzes, err := h.quizService.ListActiveQuizzes(c.Context())
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to retrieve quizzes: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Failed to retrieve quizzes",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "Quizzes retrieved successfully",
|
||||
Data: quizzes,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateQuiz handles the request to create a new quiz.
|
||||
// @Summary Create a new quiz
|
||||
// @Description Creates a new quiz (admin/operator only)
|
||||
// @Tags quizzes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param quiz body models.Quiz true "Quiz object"
|
||||
// @Success 201 {object} object{success=bool,message=string,data=models.Quiz}
|
||||
// @Failure 400 {object} object{success=bool,message=string}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/admin/quizzes [post]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *QuizHandler) CreateQuiz(c *fiber.Ctx) error {
|
||||
var quiz models.Quiz
|
||||
|
||||
if err := c.BodyParser(&quiz); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Cannot parse JSON",
|
||||
})
|
||||
}
|
||||
|
||||
createdQuiz, err := h.quizService.CreateQuiz(c.Context(), &quiz)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to create quiz: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Failed to create quiz",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(Response{
|
||||
Success: true,
|
||||
Message: "Quiz created successfully",
|
||||
Data: createdQuiz,
|
||||
})
|
||||
}
|
||||
|
||||
// GetQuizByID handles the request to get a single quiz by its ID.
|
||||
// @Summary Get quiz by ID
|
||||
// @Description Returns a single quiz with all its questions
|
||||
// @Tags quizzes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Quiz ID"
|
||||
// @Success 200 {object} object{success=bool,message=string,data=models.Quiz}
|
||||
// @Failure 400 {object} object{success=bool,message=string}
|
||||
// @Failure 404 {object} object{success=bool,message=string}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/quizzes/{id} [get]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *QuizHandler) GetQuizByID(c *fiber.Ctx) error {
|
||||
id, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Invalid quiz ID",
|
||||
})
|
||||
}
|
||||
|
||||
quiz, err := h.quizService.GetQuizByID(c.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Quiz not found",
|
||||
})
|
||||
}
|
||||
log.Printf("ERROR: Failed to get quiz by ID: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Failed to retrieve quiz",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "Quiz retrieved successfully",
|
||||
Data: quiz,
|
||||
})
|
||||
}
|
||||
|
||||
// SubmitQuiz handles the submission of quiz answers.
|
||||
// @Summary Submit quiz answers
|
||||
// @Description Submits quiz answers and calculates score/stars earned
|
||||
// @Tags quizzes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Quiz ID"
|
||||
// @Param submission body models.SubmissionRequest true "Quiz submission"
|
||||
// @Success 200 {object} object{success=bool,message=string,data=object}
|
||||
// @Failure 400 {object} object{success=bool,message=string}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/quizzes/{id}/submit [post]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *QuizHandler) SubmitQuiz(c *fiber.Ctx) error {
|
||||
quizID, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Invalid quiz ID",
|
||||
})
|
||||
}
|
||||
|
||||
var submission models.SubmissionRequest
|
||||
if err := c.BodyParser(&submission); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Cannot parse submission JSON",
|
||||
})
|
||||
}
|
||||
|
||||
// Get user data from auth middleware
|
||||
userData := middleware.GetTelegramUser(c)
|
||||
if userData == nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(Response{
|
||||
Success: false,
|
||||
Message: "User not authenticated",
|
||||
})
|
||||
}
|
||||
|
||||
// Ensure user exists in database
|
||||
_, err = h.userService.GetOrCreateUser(c.Context(), userData.ID, userData.FirstName, userData.LastName, userData.Username, userData.PhotoURL)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to get or create user: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Failed to create user profile",
|
||||
})
|
||||
}
|
||||
|
||||
result, err := h.quizService.SubmitQuiz(c.Context(), userData.ID, quizID, submission)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to submit quiz: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "Quiz submitted successfully",
|
||||
Data: result,
|
||||
})
|
||||
}
|
||||
|
||||
// CanRepeat handles the request to check if a user can repeat a quiz.
|
||||
// @Summary Check if quiz can be repeated
|
||||
// @Description Checks if user can repeat a quiz and when it will be available
|
||||
// @Tags quizzes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Quiz ID"
|
||||
// @Success 200 {object} object{success=bool,message=string,data=models.CanRepeatResponse}
|
||||
// @Failure 400 {object} object{success=bool,message=string}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/quizzes/{id}/can-repeat [get]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *QuizHandler) CanRepeat(c *fiber.Ctx) error {
|
||||
quizID, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Invalid quiz ID",
|
||||
})
|
||||
}
|
||||
|
||||
// Get user data from auth middleware
|
||||
userData := middleware.GetTelegramUser(c)
|
||||
if userData == nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(Response{
|
||||
Success: false,
|
||||
Message: "User not authenticated",
|
||||
})
|
||||
}
|
||||
|
||||
canRepeatResponse, err := h.quizService.CanUserRepeatQuiz(c.Context(), userData.ID, quizID)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to check if quiz can be repeated: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Failed to check quiz status",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "Quiz status retrieved successfully",
|
||||
Data: canRepeatResponse,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateQuiz handles the request to update an existing quiz.
|
||||
// @Summary Update a quiz
|
||||
// @Description Updates an existing quiz (admin only)
|
||||
// @Tags quizzes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Quiz ID"
|
||||
// @Param quiz body models.Quiz true "Updated quiz object"
|
||||
// @Success 200 {object} object{success=bool,message=string,data=models.Quiz}
|
||||
// @Failure 400 {object} object{success=bool,message=string}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/admin/quizzes/{id} [put]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *QuizHandler) UpdateQuiz(c *fiber.Ctx) error {
|
||||
id, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Invalid quiz ID",
|
||||
})
|
||||
}
|
||||
|
||||
var quiz models.Quiz
|
||||
if err := c.BodyParser(&quiz); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Cannot parse JSON",
|
||||
})
|
||||
}
|
||||
|
||||
updatedQuiz, err := h.quizService.UpdateQuiz(c.Context(), id, &quiz)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to update quiz: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "Quiz updated successfully",
|
||||
Data: updatedQuiz,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteQuiz handles the request to delete a quiz.
|
||||
// @Summary Delete a quiz
|
||||
// @Description Deletes a quiz (admin only)
|
||||
// @Tags quizzes
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Quiz ID"
|
||||
// @Success 200 {object} object{success=bool,message=string}
|
||||
// @Failure 400 {object} object{success=bool,message=string}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/admin/quizzes/{id} [delete]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *QuizHandler) DeleteQuiz(c *fiber.Ctx) error {
|
||||
id, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Invalid quiz ID",
|
||||
})
|
||||
}
|
||||
|
||||
err = h.quizService.DeleteQuiz(c.Context(), id)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to delete quiz: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "Quiz deleted successfully",
|
||||
})
|
||||
}
|
||||
216
backend/internal/handlers/reward_handler.go
Normal file
216
backend/internal/handlers/reward_handler.go
Normal file
@ -0,0 +1,216 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sno/internal/middleware"
|
||||
"sno/internal/models"
|
||||
"sno/internal/service"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// RewardHandler handles HTTP requests for rewards.
|
||||
type RewardHandler struct {
|
||||
rewardService service.RewardService
|
||||
}
|
||||
|
||||
// NewRewardHandler creates a new instance of a reward handler.
|
||||
func NewRewardHandler(s service.RewardService) *RewardHandler {
|
||||
return &RewardHandler{rewardService: s}
|
||||
}
|
||||
|
||||
// CreateReward handles the request to create a new reward.
|
||||
// @Summary Create a new reward
|
||||
// @Description Creates a new reward that users can purchase with stars (admin/operator only)
|
||||
// @Tags rewards
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param reward body models.Reward true "Reward object"
|
||||
// @Success 201 {object} object{success=bool,message=string,data=models.Reward}
|
||||
// @Failure 400 {object} object{success=bool,message=string}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/admin/rewards [post]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *RewardHandler) CreateReward(c *fiber.Ctx) error {
|
||||
var reward models.Reward
|
||||
if err := c.BodyParser(&reward); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Cannot parse JSON",
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Get CreatedBy from admin auth middleware
|
||||
|
||||
createdReward, err := h.rewardService.CreateReward(c.Context(), &reward)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to create reward: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(Response{
|
||||
Success: true,
|
||||
Message: "Reward created successfully",
|
||||
Data: createdReward,
|
||||
})
|
||||
}
|
||||
|
||||
// GetAllRewards handles the request to get all active rewards.
|
||||
// @Summary Get all active rewards
|
||||
// @Description Returns a list of all active rewards available for purchase
|
||||
// @Tags rewards
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{success=bool,message=string,data=[]models.Reward}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/rewards [get]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *RewardHandler) GetAllRewards(c *fiber.Ctx) error {
|
||||
rewards, err := h.rewardService.ListActiveRewards(c.Context())
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to list rewards: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Failed to retrieve rewards",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "Rewards retrieved successfully",
|
||||
Data: rewards,
|
||||
})
|
||||
}
|
||||
|
||||
// PurchaseReward handles the request for a user to purchase a reward.
|
||||
// @Summary Purchase a reward
|
||||
// @Description Allows a user to purchase a reward using their stars
|
||||
// @Tags rewards
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Reward ID"
|
||||
// @Success 200 {object} object{success=bool,message=string,data=models.Purchase}
|
||||
// @Failure 400 {object} object{success=bool,message=string}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/rewards/{id}/purchase [post]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *RewardHandler) PurchaseReward(c *fiber.Ctx) error {
|
||||
rewardID, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Invalid reward ID",
|
||||
})
|
||||
}
|
||||
|
||||
// Get user data from auth middleware
|
||||
userData := middleware.GetTelegramUser(c)
|
||||
if userData == nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(Response{
|
||||
Success: false,
|
||||
Message: "User not authenticated",
|
||||
})
|
||||
}
|
||||
|
||||
purchase, err := h.rewardService.PurchaseReward(c.Context(), userData.ID, rewardID)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to purchase reward: %v", err)
|
||||
// Potentially return different status codes based on error type
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "Reward purchased successfully",
|
||||
Data: purchase,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateReward handles the request to update an existing reward.
|
||||
// @Summary Update a reward
|
||||
// @Description Updates an existing reward (admin only)
|
||||
// @Tags rewards
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Reward ID"
|
||||
// @Param reward body models.Reward true "Updated reward object"
|
||||
// @Success 200 {object} object{success=bool,message=string,data=models.Reward}
|
||||
// @Failure 400 {object} object{success=bool,message=string}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/admin/rewards/{id} [put]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *RewardHandler) UpdateReward(c *fiber.Ctx) error {
|
||||
id, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Invalid reward ID",
|
||||
})
|
||||
}
|
||||
|
||||
var reward models.Reward
|
||||
if err := c.BodyParser(&reward); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Cannot parse JSON",
|
||||
})
|
||||
}
|
||||
|
||||
updatedReward, err := h.rewardService.UpdateReward(c.Context(), id, &reward)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to update reward: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "Reward updated successfully",
|
||||
Data: updatedReward,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteReward handles the request to delete a reward.
|
||||
// @Summary Delete a reward
|
||||
// @Description Deletes a reward (admin only)
|
||||
// @Tags rewards
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Reward ID"
|
||||
// @Success 200 {object} object{success=bool,message=string}
|
||||
// @Failure 400 {object} object{success=bool,message=string}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/admin/rewards/{id} [delete]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *RewardHandler) DeleteReward(c *fiber.Ctx) error {
|
||||
id, err := strconv.Atoi(c.Params("id"))
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Invalid reward ID",
|
||||
})
|
||||
}
|
||||
|
||||
err = h.rewardService.DeleteReward(c.Context(), id)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to delete reward: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "Reward deleted successfully",
|
||||
})
|
||||
}
|
||||
127
backend/internal/handlers/user_handler.go
Normal file
127
backend/internal/handlers/user_handler.go
Normal file
@ -0,0 +1,127 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sno/internal/middleware"
|
||||
"sno/internal/service"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// UserHandler handles HTTP requests for users.
|
||||
type UserHandler struct {
|
||||
userService service.UserService
|
||||
}
|
||||
|
||||
// NewUserHandler creates a new instance of a user handler.
|
||||
func NewUserHandler(s service.UserService) *UserHandler {
|
||||
return &UserHandler{userService: s}
|
||||
}
|
||||
|
||||
// GetMe handles the request to get the current user's profile.
|
||||
// @Summary Get current user profile
|
||||
// @Description Returns the current user's profile information including balance
|
||||
// @Tags user
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{success=bool,message=string,data=models.User}
|
||||
// @Failure 404 {object} object{success=bool,message=string}
|
||||
// @Router /api/me [get]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *UserHandler) GetMe(c *fiber.Ctx) error {
|
||||
// Get user data from auth middleware
|
||||
userData := middleware.GetTelegramUser(c)
|
||||
if userData == nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(Response{
|
||||
Success: false,
|
||||
Message: "User not authenticated",
|
||||
})
|
||||
}
|
||||
|
||||
user, err := h.userService.GetUserProfile(c.Context(), userData.ID)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to get user profile: %v", err)
|
||||
return c.Status(fiber.StatusNotFound).JSON(Response{
|
||||
Success: false,
|
||||
Message: "User not found",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "User profile retrieved successfully",
|
||||
Data: user,
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserPurchases handles the request to get the user's purchase history.
|
||||
// @Summary Get user purchase history
|
||||
// @Description Returns the current user's purchase history
|
||||
// @Tags user
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{success=bool,message=string,data=[]models.Purchase}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/user/purchases [get]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *UserHandler) GetUserPurchases(c *fiber.Ctx) error {
|
||||
// Get user data from auth middleware
|
||||
userData := middleware.GetTelegramUser(c)
|
||||
if userData == nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(Response{
|
||||
Success: false,
|
||||
Message: "User not authenticated",
|
||||
})
|
||||
}
|
||||
|
||||
purchases, err := h.userService.GetUserPurchases(c.Context(), userData.ID)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to get user purchases: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Failed to retrieve user purchases",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "User purchases retrieved successfully",
|
||||
Data: purchases,
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserTransactions handles the request to get the user's transaction history.
|
||||
// @Summary Get user transaction history
|
||||
// @Description Returns the current user's transaction history (earned/spent stars)
|
||||
// @Tags user
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} object{success=bool,message=string,data=[]models.Transaction}
|
||||
// @Failure 500 {object} object{success=bool,message=string}
|
||||
// @Router /api/user/transactions [get]
|
||||
// @Security ApiKeyAuth
|
||||
func (h *UserHandler) GetUserTransactions(c *fiber.Ctx) error {
|
||||
// Get user data from auth middleware
|
||||
userData := middleware.GetTelegramUser(c)
|
||||
if userData == nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(Response{
|
||||
Success: false,
|
||||
Message: "User not authenticated",
|
||||
})
|
||||
}
|
||||
|
||||
transactions, err := h.userService.GetUserTransactions(c.Context(), userData.ID)
|
||||
if err != nil {
|
||||
log.Printf("ERROR: Failed to get user transactions: %v", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(Response{
|
||||
Success: false,
|
||||
Message: "Failed to retrieve user transactions",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Response{
|
||||
Success: true,
|
||||
Message: "User transactions retrieved successfully",
|
||||
Data: transactions,
|
||||
})
|
||||
}
|
||||
184
backend/internal/middleware/auth.go
Normal file
184
backend/internal/middleware/auth.go
Normal file
@ -0,0 +1,184 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// TelegramUserData represents the parsed Telegram user data
|
||||
type TelegramUserData struct {
|
||||
ID int64 `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
PhotoURL string `json:"photo_url,omitempty"`
|
||||
AuthDate int64 `json:"auth_date"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
// AuthConfig holds configuration for the auth middleware
|
||||
type AuthConfig struct {
|
||||
BotToken string
|
||||
}
|
||||
|
||||
// AuthMiddleware creates a middleware for Telegram WebApp authentication
|
||||
func AuthMiddleware(config AuthConfig) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
// Skip authentication for OPTIONS preflight requests
|
||||
if c.Method() == "OPTIONS" {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// Get initData from header
|
||||
initData := c.Get("X-Telegram-WebApp-Init-Data")
|
||||
if initData == "" {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"message": "Missing Telegram init data",
|
||||
})
|
||||
}
|
||||
|
||||
// Parse and validate the init data
|
||||
userData, err := ValidateTelegramInitData(initData, config.BotToken)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("Invalid Telegram init data: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
// Check if auth date is not too old (5 minutes)
|
||||
authTime := time.Unix(userData.AuthDate, 0)
|
||||
if time.Since(authTime) > 5*time.Minute {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"message": "Auth data expired",
|
||||
})
|
||||
}
|
||||
|
||||
// Store user data in context for handlers to use
|
||||
c.Locals("telegram_user", userData)
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateTelegramInitData validates Telegram WebApp init data
|
||||
func ValidateTelegramInitData(initData, botToken string) (*TelegramUserData, error) {
|
||||
|
||||
// Parse query parameters
|
||||
values, err := url.ParseQuery(initData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse init data: %w", err)
|
||||
}
|
||||
|
||||
|
||||
// Extract hash
|
||||
hash := values.Get("hash")
|
||||
if hash == "" {
|
||||
return nil, errors.New("missing hash in init data")
|
||||
}
|
||||
|
||||
// Build data check string
|
||||
var dataCheckStrings []string
|
||||
|
||||
// Collect all parameters except hash
|
||||
for key, vals := range values {
|
||||
if key == "hash" {
|
||||
continue
|
||||
}
|
||||
for _, val := range vals {
|
||||
dataCheckStrings = append(dataCheckStrings, fmt.Sprintf("%s=%s", key, val))
|
||||
}
|
||||
}
|
||||
|
||||
// Sort parameters alphabetically
|
||||
sort.Strings(dataCheckStrings)
|
||||
|
||||
// Build data check string
|
||||
dataCheckString := strings.Join(dataCheckStrings, "\n")
|
||||
|
||||
// Create secret key
|
||||
secretKey := hmac.New(sha256.New, []byte("WebAppData"))
|
||||
secretKey.Write([]byte(botToken))
|
||||
|
||||
// Calculate HMAC
|
||||
h := hmac.New(sha256.New, secretKey.Sum(nil))
|
||||
h.Write([]byte(dataCheckString))
|
||||
calculatedHash := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
// Compare hashes
|
||||
if calculatedHash != hash {
|
||||
return nil, errors.New("hash verification failed")
|
||||
}
|
||||
|
||||
// Parse user data
|
||||
userData := &TelegramUserData{
|
||||
Hash: hash,
|
||||
AuthDate: mustParseInt64(values.Get("auth_date")),
|
||||
}
|
||||
|
||||
// Parse user field if present
|
||||
if userStr := values.Get("user"); userStr != "" {
|
||||
|
||||
// Parse user JSON data
|
||||
var userMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(userStr), &userMap); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse user JSON data: %w", err)
|
||||
}
|
||||
|
||||
// Extract user data from JSON
|
||||
if id, ok := userMap["id"].(float64); ok {
|
||||
userData.ID = int64(id)
|
||||
}
|
||||
if firstName, ok := userMap["first_name"].(string); ok {
|
||||
userData.FirstName = firstName
|
||||
}
|
||||
if lastName, ok := userMap["last_name"].(string); ok {
|
||||
userData.LastName = lastName
|
||||
}
|
||||
if username, ok := userMap["username"].(string); ok {
|
||||
userData.Username = username
|
||||
}
|
||||
if photoURL, ok := userMap["photo_url"].(string); ok {
|
||||
userData.PhotoURL = photoURL
|
||||
}
|
||||
|
||||
} else {
|
||||
}
|
||||
|
||||
return userData, nil
|
||||
}
|
||||
|
||||
// mustParseInt64 helper function to parse int64
|
||||
func mustParseInt64(s string) int64 {
|
||||
i, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// GetTelegramUser helper function to get user data from context
|
||||
func GetTelegramUser(c *fiber.Ctx) *TelegramUserData {
|
||||
if user, ok := c.Locals("telegram_user").(*TelegramUserData); ok {
|
||||
return user
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequireAuth is a convenience function that returns a handler requiring authentication
|
||||
func RequireAuth(config AuthConfig) fiber.Handler {
|
||||
return AuthMiddleware(config)
|
||||
}
|
||||
154
backend/internal/middleware/rbac.go
Normal file
154
backend/internal/middleware/rbac.go
Normal file
@ -0,0 +1,154 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sno/internal/service"
|
||||
"sno/internal/types"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// AdminConfig holds configuration for admin middleware
|
||||
type AdminConfig struct {
|
||||
AdminService service.AdminService
|
||||
}
|
||||
|
||||
// RequireRole middleware to check user roles
|
||||
func RequireRole(roles ...types.UserRole) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
// Skip role checking for OPTIONS preflight requests
|
||||
if c.Method() == "OPTIONS" {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
userData := GetTelegramUser(c)
|
||||
if userData == nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"message": "User not authenticated",
|
||||
})
|
||||
}
|
||||
|
||||
// Check if user has any of the required roles
|
||||
userRole, err := getUserRole(c, userData.ID)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"message": "Failed to verify user role",
|
||||
})
|
||||
}
|
||||
|
||||
for _, role := range roles {
|
||||
if userRole == role {
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("Insufficient permissions. Required role: one of %v", roles),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin middleware to check if user is admin
|
||||
func RequireAdmin(config AdminConfig) fiber.Handler {
|
||||
return RequireRole(types.RoleAdmin)
|
||||
}
|
||||
|
||||
// RequireOperator middleware to check if user is operator or admin
|
||||
func RequireOperator(config AdminConfig) fiber.Handler {
|
||||
return RequireRole(types.RoleAdmin, types.RoleOperator)
|
||||
}
|
||||
|
||||
// getUserRole retrieves user role from database or cache
|
||||
func getUserRole(c *fiber.Ctx, userID int64) (types.UserRole, error) {
|
||||
// For now, we'll check if user exists in admins table
|
||||
// In production, you might want to cache this in Redis
|
||||
|
||||
// Try to get from context first (cached)
|
||||
if role, ok := c.Locals("user_role").(types.UserRole); ok {
|
||||
return role, nil
|
||||
}
|
||||
|
||||
// Get admin service from context
|
||||
adminService, ok := c.Locals("admin_service").(service.AdminService)
|
||||
if !ok {
|
||||
// Fallback: if no admin service, assume user role
|
||||
return types.RoleUser, nil
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
role, err := adminService.GetUserRole(c.Context(), userID)
|
||||
if err != nil {
|
||||
// If user not found in admins table, they are regular user
|
||||
return types.RoleUser, nil
|
||||
}
|
||||
|
||||
// Cache role in context
|
||||
c.Locals("user_role", role)
|
||||
|
||||
return role, nil
|
||||
}
|
||||
|
||||
// AdminMiddleware middleware for admin routes
|
||||
func AdminMiddleware(config AdminConfig) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
// Store admin service in context for role checking
|
||||
c.Locals("admin_service", config.AdminService)
|
||||
|
||||
// Apply auth middleware first
|
||||
return AuthMiddleware(AuthConfig{})(c)
|
||||
}
|
||||
}
|
||||
|
||||
// WithAdminRoles applies both admin middleware and role checking
|
||||
func WithAdminRoles(config AdminConfig, roles ...types.UserRole) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
// First, apply admin middleware (includes auth)
|
||||
if err := AdminMiddleware(config)(c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Then check roles
|
||||
return RequireRole(roles...)(c)
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience functions
|
||||
func IsAdmin(c *fiber.Ctx) bool {
|
||||
userData := GetTelegramUser(c)
|
||||
if userData == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
role, err := getUserRole(c, userData.ID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return role == types.RoleAdmin
|
||||
}
|
||||
|
||||
func IsOperator(c *fiber.Ctx) bool {
|
||||
userData := GetTelegramUser(c)
|
||||
if userData == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
role, err := getUserRole(c, userData.ID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return role == types.RoleOperator || role == types.RoleAdmin
|
||||
}
|
||||
|
||||
func GetCurrentUserRole(c *fiber.Ctx) (types.UserRole, error) {
|
||||
userData := GetTelegramUser(c)
|
||||
if userData == nil {
|
||||
return "", fmt.Errorf("user not authenticated")
|
||||
}
|
||||
|
||||
return getUserRole(c, userData.ID)
|
||||
}
|
||||
227
backend/internal/models/models.go
Normal file
227
backend/internal/models/models.go
Normal file
@ -0,0 +1,227 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// --- Enums (matches DB types) ---
|
||||
type QuestionType string
|
||||
const (
|
||||
Single QuestionType = "single"
|
||||
Multiple QuestionType = "multiple"
|
||||
)
|
||||
|
||||
type DeliveryType string
|
||||
const (
|
||||
Physical DeliveryType = "physical"
|
||||
Digital DeliveryType = "digital"
|
||||
)
|
||||
|
||||
type PurchaseStatus string
|
||||
const (
|
||||
Pending PurchaseStatus = "pending"
|
||||
Delivered PurchaseStatus = "delivered"
|
||||
Cancelled PurchaseStatus = "cancelled"
|
||||
)
|
||||
|
||||
type QRScanType string
|
||||
const (
|
||||
QRReward QRScanType = "reward"
|
||||
QRQuiz QRScanType = "quiz"
|
||||
QRShop QRScanType = "shop"
|
||||
)
|
||||
|
||||
type QRScanSource string
|
||||
const (
|
||||
InApp QRScanSource = "in_app"
|
||||
External QRScanSource = "external"
|
||||
)
|
||||
|
||||
type AdminRole string
|
||||
const (
|
||||
RoleAdmin AdminRole = "admin"
|
||||
RoleOperator AdminRole = "operator"
|
||||
)
|
||||
|
||||
// --- Model Structs ---
|
||||
|
||||
type User struct {
|
||||
TelegramID int64 `json:"telegram_id"`
|
||||
Username *string `json:"username,omitempty"`
|
||||
FirstName *string `json:"first_name,omitempty"`
|
||||
LastName *string `json:"last_name,omitempty"`
|
||||
PhotoURL *string `json:"photo_url,omitempty"`
|
||||
StarsBalance int `json:"stars_balance"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Quiz struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
ImageURL *string `json:"image_url,omitempty"`
|
||||
RewardStars int `json:"reward_stars"`
|
||||
HasTimer bool `json:"has_timer"`
|
||||
TimerPerQuestion *int `json:"timer_per_question,omitempty"`
|
||||
CanRepeat bool `json:"can_repeat"`
|
||||
RepeatCooldownHours *int `json:"repeat_cooldown_hours,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedBy *int64 `json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Questions []Question `json:"questions,omitempty"` // This field is for API responses, not DB storage
|
||||
}
|
||||
|
||||
type Question struct {
|
||||
ID int `json:"id"`
|
||||
QuizID int `json:"quiz_id"`
|
||||
Text string `json:"text"`
|
||||
Type QuestionType `json:"type"`
|
||||
Options []Option `json:"options"` // Stored as JSONB
|
||||
OrderIndex int `json:"order_index"`
|
||||
}
|
||||
|
||||
type Option struct {
|
||||
ID int `json:"id"`
|
||||
Text string `json:"text"`
|
||||
IsCorrect bool `json:"is_correct"`
|
||||
}
|
||||
|
||||
type QuizAttempt struct {
|
||||
ID int `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
QuizID int `json:"quiz_id"`
|
||||
Score int `json:"score"`
|
||||
StarsEarned int `json:"stars_earned"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
Answers []UserAnswer `json:"answers"` // Stored as JSONB
|
||||
}
|
||||
|
||||
type Reward struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
ImageURL *string `json:"image_url,omitempty"`
|
||||
PriceStars int `json:"price_stars"`
|
||||
DeliveryType DeliveryType `json:"delivery_type"`
|
||||
Instructions *string `json:"instructions,omitempty"`
|
||||
Stock int `json:"stock"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedBy *int64 `json:"created_by,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Purchase struct {
|
||||
ID int `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
RewardID int `json:"reward_id"`
|
||||
StarsSpent int `json:"stars_spent"`
|
||||
PurchasedAt time.Time `json:"purchased_at"`
|
||||
Status PurchaseStatus `json:"status"`
|
||||
}
|
||||
|
||||
type QRScan struct {
|
||||
ID int `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Type QRScanType `json:"type"`
|
||||
Value *string `json:"value,omitempty"`
|
||||
ScannedAt time.Time `json:"scanned_at"`
|
||||
Source QRScanSource `json:"source"`
|
||||
}
|
||||
|
||||
type Admin struct {
|
||||
TelegramID int64 `json:"telegram_id"`
|
||||
Role AdminRole `json:"role"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
AddedBy *int64 `json:"added_by,omitempty"`
|
||||
AddedAt time.Time `json:"added_at"`
|
||||
}
|
||||
|
||||
|
||||
// --- API Request/Response Structs ---
|
||||
|
||||
// TransactionType defines whether stars were earned or spent.
|
||||
type TransactionType string
|
||||
|
||||
const (
|
||||
TransactionEarned TransactionType = "earned"
|
||||
TransactionSpent TransactionType = "spent"
|
||||
)
|
||||
|
||||
// Transaction represents a single entry in the user's balance history.
|
||||
type Transaction struct {
|
||||
Type TransactionType `json:"type"`
|
||||
Amount int `json:"amount"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SubmissionRequest defines the structure for a user submitting quiz answers.
|
||||
type SubmissionRequest struct {
|
||||
Answers []UserAnswer `json:"answers"`
|
||||
}
|
||||
|
||||
// UserAnswer defines a single answer provided by the user.
|
||||
type UserAnswer struct {
|
||||
QuestionID int `json:"question_id"`
|
||||
OptionIDs []int `json:"option_ids"`
|
||||
}
|
||||
|
||||
// GrantStarsRequest defines the structure for an admin to grant stars to a user.
|
||||
type GrantStarsRequest struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Amount int `json:"amount"`
|
||||
}
|
||||
|
||||
// CanRepeatResponse defines the response for the can-repeat check.
|
||||
type CanRepeatResponse struct {
|
||||
CanRepeat bool `json:"can_repeat"`
|
||||
NextAvailableAt *time.Time `json:"next_available_at,omitempty"`
|
||||
}
|
||||
|
||||
// QRValidateRequest defines the structure for a QR validation request.
|
||||
type QRValidateRequest struct {
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
|
||||
// QRValidateResponse defines the structure for a QR validation response.
|
||||
type QRValidateResponse struct {
|
||||
Type string `json:"type"` // e.g., "REWARD", "OPEN_QUIZ"
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// GenerateQRCodesRequest defines the structure for generating unique QR codes.
|
||||
type GenerateQRCodesRequest struct {
|
||||
Type string `json:"type"` // e.g., "reward", "quiz"
|
||||
Value string `json:"value"` // e.g., "50" for reward, "1" for quiz
|
||||
Count int `json:"count"` // Number of codes to generate
|
||||
}
|
||||
|
||||
// QRTokenData represents the data stored for a unique QR token
|
||||
type QRTokenData struct {
|
||||
Type string `json:"type"` // "reward", "quiz", "shop"
|
||||
Value string `json:"value"` // e.g., "50", "123"
|
||||
Used bool `json:"used"` // Whether the token has been used
|
||||
}
|
||||
|
||||
// GenerateQRCodesResponse defines the response for generating QR codes
|
||||
type GenerateQRCodesResponse struct {
|
||||
Tokens []string `json:"tokens"` // List of generated unique tokens
|
||||
}
|
||||
|
||||
// CreateOperatorRequest defines the structure for creating a new operator
|
||||
type CreateOperatorRequest struct {
|
||||
TelegramID int64 `json:"telegram_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Analytics represents various statistics and metrics for the admin dashboard
|
||||
type Analytics struct {
|
||||
TotalUsers int `json:"total_users"`
|
||||
TotalQuizzes int `json:"total_quizzes"`
|
||||
ActiveQuizzes int `json:"active_quizzes"`
|
||||
TotalRewards int `json:"total_rewards"`
|
||||
ActiveRewards int `json:"active_rewards"`
|
||||
TotalAttempts int `json:"total_attempts"`
|
||||
TotalPurchases int `json:"total_purchases"`
|
||||
TotalQRScans int `json:"total_qr_scans"`
|
||||
StarsDistributed int `json:"stars_distributed"`
|
||||
StarsSpent int `json:"stars_spent"`
|
||||
}
|
||||
25
backend/internal/redis/redis.go
Normal file
25
backend/internal/redis/redis.go
Normal file
@ -0,0 +1,25 @@
|
||||
package redis
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func Connect(redisURL string) (*redis.Client, error) {
|
||||
opts, err := redis.ParseURL(redisURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rdb := redis.NewClient(opts)
|
||||
|
||||
// Ping the server to check the connection
|
||||
if err := rdb.Ping(context.Background()).Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Println("Redis connected successfully")
|
||||
return rdb, nil
|
||||
}
|
||||
157
backend/internal/repository/admin_repository.go
Normal file
157
backend/internal/repository/admin_repository.go
Normal file
@ -0,0 +1,157 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sno/internal/models"
|
||||
"sno/internal/types"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// AdminRepository handles database operations for admins
|
||||
type AdminRepository interface {
|
||||
GetByTelegramID(ctx context.Context, telegramID int64) (*models.Admin, error)
|
||||
Create(ctx context.Context, admin *models.Admin) error
|
||||
Update(ctx context.Context, admin *models.Admin) error
|
||||
Delete(ctx context.Context, telegramID int64) error
|
||||
ListAll(ctx context.Context) ([]*models.Admin, error)
|
||||
GetUserRole(ctx context.Context, telegramID int64) (types.UserRole, error)
|
||||
}
|
||||
|
||||
// adminRepository implements AdminRepository interface
|
||||
type adminRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewAdminRepository creates a new admin repository
|
||||
func NewAdminRepository(db *pgxpool.Pool) AdminRepository {
|
||||
return &adminRepository{db: db}
|
||||
}
|
||||
|
||||
// GetByTelegramID retrieves an admin by Telegram ID
|
||||
func (r *adminRepository) GetByTelegramID(ctx context.Context, telegramID int64) (*models.Admin, error) {
|
||||
query := `
|
||||
SELECT telegram_id, role, name, added_by, added_at
|
||||
FROM admins
|
||||
WHERE telegram_id = $1
|
||||
`
|
||||
|
||||
var admin models.Admin
|
||||
err := r.db.QueryRow(ctx, query, telegramID).Scan(
|
||||
&admin.TelegramID,
|
||||
&admin.Role,
|
||||
&admin.Name,
|
||||
&admin.AddedBy,
|
||||
&admin.AddedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if err.Error() == "no rows in result set" {
|
||||
return nil, nil // Admin not found
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &admin, nil
|
||||
}
|
||||
|
||||
// Create creates a new admin record
|
||||
func (r *adminRepository) Create(ctx context.Context, admin *models.Admin) error {
|
||||
query := `
|
||||
INSERT INTO admins (telegram_id, role, name, added_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING added_at
|
||||
`
|
||||
|
||||
return r.db.QueryRow(ctx, query,
|
||||
admin.TelegramID,
|
||||
admin.Role,
|
||||
admin.Name,
|
||||
admin.AddedBy,
|
||||
).Scan(&admin.AddedAt)
|
||||
}
|
||||
|
||||
// Update updates an admin record
|
||||
func (r *adminRepository) Update(ctx context.Context, admin *models.Admin) error {
|
||||
query := `
|
||||
UPDATE admins
|
||||
SET role = $2, name = $3
|
||||
WHERE telegram_id = $1
|
||||
`
|
||||
|
||||
_, err := r.db.Exec(ctx, query,
|
||||
admin.TelegramID,
|
||||
admin.Role,
|
||||
admin.Name,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete deletes an admin record
|
||||
func (r *adminRepository) Delete(ctx context.Context, telegramID int64) error {
|
||||
query := `DELETE FROM admins WHERE telegram_id = $1`
|
||||
_, err := r.db.Exec(ctx, query, telegramID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListAll retrieves all admins
|
||||
func (r *adminRepository) ListAll(ctx context.Context) ([]*models.Admin, error) {
|
||||
query := `
|
||||
SELECT telegram_id, role, name, added_by, added_at
|
||||
FROM admins
|
||||
ORDER BY added_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.Query(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var admins []*models.Admin
|
||||
for rows.Next() {
|
||||
var admin models.Admin
|
||||
err := rows.Scan(
|
||||
&admin.TelegramID,
|
||||
&admin.Role,
|
||||
&admin.Name,
|
||||
&admin.AddedBy,
|
||||
&admin.AddedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
admins = append(admins, &admin)
|
||||
}
|
||||
|
||||
return admins, nil
|
||||
}
|
||||
|
||||
// GetUserRole retrieves user role from admins table
|
||||
func (r *adminRepository) GetUserRole(ctx context.Context, telegramID int64) (types.UserRole, error) {
|
||||
query := `
|
||||
SELECT role
|
||||
FROM admins
|
||||
WHERE telegram_id = $1
|
||||
`
|
||||
|
||||
var role models.AdminRole
|
||||
err := r.db.QueryRow(ctx, query, telegramID).Scan(&role)
|
||||
if err != nil {
|
||||
if err.Error() == "no rows in result set" {
|
||||
return types.RoleUser, nil // User is not an admin
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Convert models.AdminRole to types.UserRole
|
||||
switch role {
|
||||
case models.RoleAdmin:
|
||||
return types.RoleAdmin, nil
|
||||
case models.RoleOperator:
|
||||
return types.RoleOperator, nil
|
||||
default:
|
||||
return types.RoleUser, nil
|
||||
}
|
||||
}
|
||||
72
backend/internal/repository/purchase_repository.go
Normal file
72
backend/internal/repository/purchase_repository.go
Normal file
@ -0,0 +1,72 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sno/internal/models"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type PurchaseRepository interface {
|
||||
Create(ctx context.Context, purchase *models.Purchase) (*models.Purchase, error)
|
||||
GetByUserID(ctx context.Context, userID int64) ([]models.Purchase, error)
|
||||
}
|
||||
|
||||
type postgresPurchaseRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewPurchaseRepository(db *pgxpool.Pool) PurchaseRepository {
|
||||
return &postgresPurchaseRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *postgresPurchaseRepository) Create(ctx context.Context, purchase *models.Purchase) (*models.Purchase, error) {
|
||||
query := `
|
||||
INSERT INTO purchases (user_id, reward_id, stars_spent, status)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, purchased_at
|
||||
`
|
||||
querier := getQuerier(ctx, r.db)
|
||||
err := querier.QueryRow(ctx, query,
|
||||
purchase.UserID, purchase.RewardID, purchase.StarsSpent, purchase.Status,
|
||||
).Scan(&purchase.ID, &purchase.PurchasedAt)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return purchase, nil
|
||||
}
|
||||
|
||||
// GetByUserID retrieves all purchases for a given user, ordered by most recent first.
|
||||
func (r *postgresPurchaseRepository) GetByUserID(ctx context.Context, userID int64) ([]models.Purchase, error) {
|
||||
query := `
|
||||
SELECT id, user_id, reward_id, stars_spent, purchased_at, status
|
||||
FROM purchases
|
||||
WHERE user_id = $1
|
||||
ORDER BY purchased_at DESC
|
||||
`
|
||||
rows, err := r.db.Query(ctx, query, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var purchases []models.Purchase
|
||||
for rows.Next() {
|
||||
var p models.Purchase
|
||||
if err := rows.Scan(&p.ID, &p.UserID, &p.RewardID, &p.StarsSpent, &p.PurchasedAt, &p.Status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
purchases = append(purchases, p)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if purchases == nil {
|
||||
return []models.Purchase{}, nil
|
||||
}
|
||||
|
||||
return purchases, nil
|
||||
}
|
||||
42
backend/internal/repository/qr_scan_repository.go
Normal file
42
backend/internal/repository/qr_scan_repository.go
Normal file
@ -0,0 +1,42 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sno/internal/models"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// QRScanRepository defines the interface for QR scan data operations.
|
||||
type QRScanRepository interface {
|
||||
Create(ctx context.Context, scan *models.QRScan) (*models.QRScan, error)
|
||||
}
|
||||
|
||||
// postgresQRScanRepository implements the QRScanRepository interface.
|
||||
type postgresQRScanRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewQRScanRepository creates a new instance of a QR scan repository.
|
||||
func NewQRScanRepository(db *pgxpool.Pool) QRScanRepository {
|
||||
return &postgresQRScanRepository{db: db}
|
||||
}
|
||||
|
||||
// Create inserts a new QR scan record into the database.
|
||||
func (r *postgresQRScanRepository) Create(ctx context.Context, scan *models.QRScan) (*models.QRScan, error) {
|
||||
query := `
|
||||
INSERT INTO qr_scans (user_id, type, value, source)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, scanned_at
|
||||
`
|
||||
querier := getQuerier(ctx, r.db)
|
||||
err := querier.QueryRow(ctx, query,
|
||||
scan.UserID, scan.Type, scan.Value, scan.Source,
|
||||
).Scan(&scan.ID, &scan.ScannedAt)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return scan, nil
|
||||
}
|
||||
124
backend/internal/repository/question_repository.go
Normal file
124
backend/internal/repository/question_repository.go
Normal file
@ -0,0 +1,124 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sno/internal/models"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// QuestionRepository defines the interface for question data operations.
|
||||
type QuestionRepository interface {
|
||||
Create(ctx context.Context, question *models.Question) (*models.Question, error)
|
||||
GetByQuizID(ctx context.Context, quizID int) ([]models.Question, error)
|
||||
GetByID(ctx context.Context, id int) (*models.Question, error)
|
||||
Update(ctx context.Context, id int, question *models.Question) (*models.Question, error)
|
||||
Delete(ctx context.Context, id int) error
|
||||
}
|
||||
|
||||
// postgresQuestionRepository implements the QuestionRepository interface.
|
||||
type postgresQuestionRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewQuestionRepository creates a new instance of a question repository.
|
||||
func NewQuestionRepository(db *pgxpool.Pool) QuestionRepository {
|
||||
return &postgresQuestionRepository{db: db}
|
||||
}
|
||||
|
||||
// Create inserts a new question into the database.
|
||||
func (r *postgresQuestionRepository) Create(ctx context.Context, question *models.Question) (*models.Question, error) {
|
||||
query := `
|
||||
INSERT INTO questions (quiz_id, text, type, options, order_index)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
`
|
||||
err := r.db.QueryRow(ctx, query,
|
||||
question.QuizID, question.Text, question.Type, question.Options, question.OrderIndex,
|
||||
).Scan(&question.ID)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return question, nil
|
||||
}
|
||||
|
||||
// GetByQuizID retrieves all questions for a given quiz ID.
|
||||
func (r *postgresQuestionRepository) GetByQuizID(ctx context.Context, quizID int) ([]models.Question, error) {
|
||||
query := `
|
||||
SELECT id, quiz_id, text, type, options, order_index
|
||||
FROM questions
|
||||
WHERE quiz_id = $1
|
||||
ORDER BY order_index ASC
|
||||
`
|
||||
rows, err := r.db.Query(ctx, query, quizID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var questions []models.Question
|
||||
for rows.Next() {
|
||||
var q models.Question
|
||||
if err := rows.Scan(&q.ID, &q.QuizID, &q.Text, &q.Type, &q.Options, &q.OrderIndex); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
questions = append(questions, q)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if questions == nil {
|
||||
return []models.Question{}, nil
|
||||
}
|
||||
|
||||
return questions, nil
|
||||
}
|
||||
|
||||
// GetByID retrieves a single question by its ID.
|
||||
func (r *postgresQuestionRepository) GetByID(ctx context.Context, id int) (*models.Question, error) {
|
||||
query := `
|
||||
SELECT id, quiz_id, text, type, options, order_index
|
||||
FROM questions
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
var q models.Question
|
||||
err := r.db.QueryRow(ctx, query, id).Scan(&q.ID, &q.QuizID, &q.Text, &q.Type, &q.Options, &q.OrderIndex)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &q, nil
|
||||
}
|
||||
|
||||
// Update updates an existing question
|
||||
func (r *postgresQuestionRepository) Update(ctx context.Context, id int, question *models.Question) (*models.Question, error) {
|
||||
query := `
|
||||
UPDATE questions
|
||||
SET text = $2, type = $3, options = $4, order_index = $5
|
||||
WHERE id = $1
|
||||
RETURNING id, quiz_id, text, type, options, order_index
|
||||
`
|
||||
|
||||
var q models.Question
|
||||
err := r.db.QueryRow(ctx, query,
|
||||
id, question.Text, question.Type, question.Options, question.OrderIndex,
|
||||
).Scan(&q.ID, &q.QuizID, &q.Text, &q.Type, &q.Options, &q.OrderIndex)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &q, nil
|
||||
}
|
||||
|
||||
// Delete deletes a question
|
||||
func (r *postgresQuestionRepository) Delete(ctx context.Context, id int) error {
|
||||
_, err := r.db.Exec(ctx, "DELETE FROM questions WHERE id = $1", id)
|
||||
return err
|
||||
}
|
||||
97
backend/internal/repository/quiz_attempt_repository.go
Normal file
97
backend/internal/repository/quiz_attempt_repository.go
Normal file
@ -0,0 +1,97 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sno/internal/models"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// QuizAttemptRepository defines the interface for quiz attempt data operations.
|
||||
type QuizAttemptRepository interface {
|
||||
Create(ctx context.Context, attempt *models.QuizAttempt) (*models.QuizAttempt, error)
|
||||
GetLatestByUserIDAndQuizID(ctx context.Context, userID int64, quizID int) (*models.QuizAttempt, error)
|
||||
GetByUserID(ctx context.Context, userID int64) ([]models.QuizAttempt, error)
|
||||
}
|
||||
|
||||
// postgresQuizAttemptRepository implements the QuizAttemptRepository interface.
|
||||
type postgresQuizAttemptRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewQuizAttemptRepository creates a new instance of a quiz attempt repository.
|
||||
func NewQuizAttemptRepository(db *pgxpool.Pool) QuizAttemptRepository {
|
||||
return &postgresQuizAttemptRepository{db: db}
|
||||
}
|
||||
|
||||
// Create inserts a new quiz attempt record into the database.
|
||||
func (r *postgresQuizAttemptRepository) Create(ctx context.Context, attempt *models.QuizAttempt) (*models.QuizAttempt, error) {
|
||||
query := `
|
||||
INSERT INTO quiz_attempts (user_id, quiz_id, score, stars_earned, answers)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, completed_at
|
||||
`
|
||||
querier := getQuerier(ctx, r.db)
|
||||
err := querier.QueryRow(ctx, query,
|
||||
attempt.UserID, attempt.QuizID, attempt.Score, attempt.StarsEarned, attempt.Answers,
|
||||
).Scan(&attempt.ID, &attempt.CompletedAt)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return attempt, nil
|
||||
}
|
||||
|
||||
// GetLatestByUserIDAndQuizID retrieves the most recent quiz attempt for a given user and quiz.
|
||||
func (r *postgresQuizAttemptRepository) GetLatestByUserIDAndQuizID(ctx context.Context, userID int64, quizID int) (*models.QuizAttempt, error) {
|
||||
query := `
|
||||
SELECT id, user_id, quiz_id, score, stars_earned, completed_at, answers
|
||||
FROM quiz_attempts
|
||||
WHERE user_id = $1 AND quiz_id = $2
|
||||
ORDER BY completed_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
var attempt models.QuizAttempt
|
||||
err := r.db.QueryRow(ctx, query, userID, quizID).Scan(
|
||||
&attempt.ID, &attempt.UserID, &attempt.QuizID, &attempt.Score, &attempt.StarsEarned, &attempt.CompletedAt, &attempt.Answers,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err // pgx.ErrNoRows is a common error here, handled by the service
|
||||
}
|
||||
return &attempt, nil
|
||||
}
|
||||
|
||||
// GetByUserID retrieves all quiz attempts for a given user.
|
||||
func (r *postgresQuizAttemptRepository) GetByUserID(ctx context.Context, userID int64) ([]models.QuizAttempt, error) {
|
||||
query := `
|
||||
SELECT id, user_id, quiz_id, score, stars_earned, completed_at, answers
|
||||
FROM quiz_attempts
|
||||
WHERE user_id = $1
|
||||
ORDER BY completed_at DESC
|
||||
`
|
||||
rows, err := r.db.Query(ctx, query, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var attempts []models.QuizAttempt
|
||||
for rows.Next() {
|
||||
var a models.QuizAttempt
|
||||
if err := rows.Scan(&a.ID, &a.UserID, &a.QuizID, &a.Score, &a.StarsEarned, &a.CompletedAt, &a.Answers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attempts = append(attempts, a)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if attempts == nil {
|
||||
return []models.QuizAttempt{}, nil
|
||||
}
|
||||
|
||||
return attempts, nil
|
||||
}
|
||||
168
backend/internal/repository/quiz_repository.go
Normal file
168
backend/internal/repository/quiz_repository.go
Normal file
@ -0,0 +1,168 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sno/internal/models"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// QuizRepository defines the interface for quiz data operations.
|
||||
type QuizRepository interface {
|
||||
GetAllActive(ctx context.Context) ([]models.Quiz, error)
|
||||
Create(ctx context.Context, quiz *models.Quiz) (*models.Quiz, error)
|
||||
GetByID(ctx context.Context, id int) (*models.Quiz, error)
|
||||
Update(ctx context.Context, id int, quiz *models.Quiz) (*models.Quiz, error)
|
||||
Delete(ctx context.Context, id int) error
|
||||
}
|
||||
|
||||
// postgresQuizRepository implements the QuizRepository interface for PostgreSQL.
|
||||
type postgresQuizRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewQuizRepository creates a new instance of a quiz repository.
|
||||
func NewQuizRepository(db *pgxpool.Pool) QuizRepository {
|
||||
return &postgresQuizRepository{db: db}
|
||||
}
|
||||
|
||||
// GetAllActive retrieves all quizzes where is_active is true.
|
||||
func (r *postgresQuizRepository) GetAllActive(ctx context.Context) ([]models.Quiz, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id, title, description, image_url, reward_stars,
|
||||
has_timer, timer_per_question, can_repeat, repeat_cooldown_hours,
|
||||
is_active, created_by, created_at
|
||||
FROM quizzes
|
||||
WHERE is_active = true
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.Query(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var quizzes []models.Quiz
|
||||
for rows.Next() {
|
||||
var q models.Quiz
|
||||
if err := rows.Scan(
|
||||
&q.ID, &q.Title, &q.Description, &q.ImageURL, &q.RewardStars,
|
||||
&q.HasTimer, &q.TimerPerQuestion, &q.CanRepeat, &q.RepeatCooldownHours,
|
||||
&q.IsActive, &q.CreatedBy, &q.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quizzes = append(quizzes, q)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if quizzes == nil {
|
||||
return []models.Quiz{}, nil
|
||||
}
|
||||
|
||||
return quizzes, nil
|
||||
}
|
||||
|
||||
// Create inserts a new quiz into the database.
|
||||
func (r *postgresQuizRepository) Create(ctx context.Context, quiz *models.Quiz) (*models.Quiz, error) {
|
||||
query := `
|
||||
INSERT INTO quizzes (title, description, image_url, reward_stars, has_timer, timer_per_question, can_repeat, repeat_cooldown_hours, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, is_active, created_at
|
||||
`
|
||||
err := r.db.QueryRow(ctx, query,
|
||||
quiz.Title, quiz.Description, quiz.ImageURL, quiz.RewardStars, quiz.HasTimer, quiz.TimerPerQuestion, quiz.CanRepeat, quiz.RepeatCooldownHours, quiz.CreatedBy,
|
||||
).Scan(&quiz.ID, &quiz.IsActive, &quiz.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return quiz, nil
|
||||
}
|
||||
|
||||
// GetByID retrieves a single quiz by its ID.
|
||||
func (r *postgresQuizRepository) GetByID(ctx context.Context, id int) (*models.Quiz, error) {
|
||||
query := `
|
||||
SELECT
|
||||
id, title, description, image_url, reward_stars,
|
||||
has_timer, timer_per_question, can_repeat, repeat_cooldown_hours,
|
||||
is_active, created_by, created_at
|
||||
FROM quizzes
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
var q models.Quiz
|
||||
err := r.db.QueryRow(ctx, query, id).Scan(
|
||||
&q.ID, &q.Title, &q.Description, &q.ImageURL, &q.RewardStars,
|
||||
&q.HasTimer, &q.TimerPerQuestion, &q.CanRepeat, &q.RepeatCooldownHours,
|
||||
&q.IsActive, &q.CreatedBy, &q.CreatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &q, nil
|
||||
}
|
||||
|
||||
// Update updates an existing quiz
|
||||
func (r *postgresQuizRepository) Update(ctx context.Context, id int, quiz *models.Quiz) (*models.Quiz, error) {
|
||||
query := `
|
||||
UPDATE quizzes
|
||||
SET title = $2, description = $3, image_url = $4, reward_stars = $5,
|
||||
has_timer = $6, timer_per_question = $7, can_repeat = $8, repeat_cooldown_hours = $9,
|
||||
is_active = $10
|
||||
WHERE id = $1
|
||||
RETURNING id, title, description, image_url, reward_stars,
|
||||
has_timer, timer_per_question, can_repeat, repeat_cooldown_hours,
|
||||
is_active, created_by, created_at
|
||||
`
|
||||
|
||||
var q models.Quiz
|
||||
err := r.db.QueryRow(ctx, query,
|
||||
id, quiz.Title, quiz.Description, quiz.ImageURL, quiz.RewardStars,
|
||||
quiz.HasTimer, quiz.TimerPerQuestion, quiz.CanRepeat, quiz.RepeatCooldownHours,
|
||||
quiz.IsActive,
|
||||
).Scan(
|
||||
&q.ID, &q.Title, &q.Description, &q.ImageURL, &q.RewardStars,
|
||||
&q.HasTimer, &q.TimerPerQuestion, &q.CanRepeat, &q.RepeatCooldownHours,
|
||||
&q.IsActive, &q.CreatedBy, &q.CreatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &q, nil
|
||||
}
|
||||
|
||||
// Delete deletes a quiz and its associated questions
|
||||
func (r *postgresQuizRepository) Delete(ctx context.Context, id int) error {
|
||||
// Start transaction
|
||||
tx, err := r.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Delete associated questions first
|
||||
_, err = tx.Exec(ctx, "DELETE FROM questions WHERE quiz_id = $1", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the quiz
|
||||
_, err = tx.Exec(ctx, "DELETE FROM quizzes WHERE id = $1", id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
25
backend/internal/repository/repository.go
Normal file
25
backend/internal/repository/repository.go
Normal file
@ -0,0 +1,25 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Querier defines an interface for executing queries, which can be a pgxpool.Pool or a pgx.Tx.
|
||||
type Querier interface {
|
||||
Exec(ctx context.Context, sql string, arguments ...interface{}) (pgconn.CommandTag, error)
|
||||
Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error)
|
||||
QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row
|
||||
}
|
||||
|
||||
// getQuerier retrieves a transaction from the context if it exists, otherwise returns the pool.
|
||||
// The transaction is expected to be stored in the context with the key "tx".
|
||||
func getQuerier(ctx context.Context, db *pgxpool.Pool) Querier {
|
||||
if tx, ok := ctx.Value("tx").(pgx.Tx); ok {
|
||||
return tx
|
||||
}
|
||||
return db
|
||||
}
|
||||
138
backend/internal/repository/reward_repository.go
Normal file
138
backend/internal/repository/reward_repository.go
Normal file
@ -0,0 +1,138 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sno/internal/models"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// RewardRepository defines the interface for reward data operations.
|
||||
type RewardRepository interface {
|
||||
Create(ctx context.Context, reward *models.Reward) (*models.Reward, error)
|
||||
GetAllActive(ctx context.Context) ([]models.Reward, error)
|
||||
GetByID(ctx context.Context, id int) (*models.Reward, error)
|
||||
UpdateStock(ctx context.Context, id int, quantity int) error
|
||||
Update(ctx context.Context, id int, reward *models.Reward) (*models.Reward, error)
|
||||
Delete(ctx context.Context, id int) error
|
||||
}
|
||||
|
||||
// postgresRewardRepository implements the RewardRepository interface.
|
||||
type postgresRewardRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewRewardRepository creates a new instance of a reward repository.
|
||||
func NewRewardRepository(db *pgxpool.Pool) RewardRepository {
|
||||
return &postgresRewardRepository{db: db}
|
||||
}
|
||||
|
||||
// Create inserts a new reward into the database.
|
||||
func (r *postgresRewardRepository) Create(ctx context.Context, reward *models.Reward) (*models.Reward, error) {
|
||||
query := `
|
||||
INSERT INTO rewards (title, description, image_url, price_stars, delivery_type, instructions, stock, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, is_active, created_at
|
||||
`
|
||||
err := r.db.QueryRow(ctx, query,
|
||||
reward.Title, reward.Description, reward.ImageURL, reward.PriceStars, reward.DeliveryType, reward.Instructions, reward.Stock, reward.CreatedBy,
|
||||
).Scan(&reward.ID, &reward.IsActive, &reward.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return reward, nil
|
||||
}
|
||||
|
||||
// GetAllActive retrieves all active rewards that are in stock.
|
||||
func (r *postgresRewardRepository) GetAllActive(ctx context.Context) ([]models.Reward, error) {
|
||||
query := `
|
||||
SELECT id, title, description, image_url, price_stars, delivery_type, instructions, stock, is_active, created_at
|
||||
FROM rewards
|
||||
WHERE is_active = true AND (stock > 0 OR stock = -1) -- Using -1 for infinite stock
|
||||
ORDER BY price_stars ASC
|
||||
`
|
||||
rows, err := r.db.Query(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var rewards []models.Reward
|
||||
for rows.Next() {
|
||||
var p models.Reward
|
||||
if err := rows.Scan(&p.ID, &p.Title, &p.Description, &p.ImageURL, &p.PriceStars, &p.DeliveryType, &p.Instructions, &p.Stock, &p.IsActive, &p.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rewards = append(rewards, p)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if rewards == nil {
|
||||
return []models.Reward{}, nil
|
||||
}
|
||||
|
||||
return rewards, nil
|
||||
}
|
||||
|
||||
// GetByID retrieves a single reward by its ID.
|
||||
func (r *postgresRewardRepository) GetByID(ctx context.Context, id int) (*models.Reward, error) {
|
||||
query := `
|
||||
SELECT id, title, description, image_url, price_stars, delivery_type, instructions, stock, is_active, created_at, created_by
|
||||
FROM rewards
|
||||
WHERE id = $1
|
||||
`
|
||||
var p models.Reward
|
||||
err := r.db.QueryRow(ctx, query, id).Scan(
|
||||
&p.ID, &p.Title, &p.Description, &p.ImageURL, &p.PriceStars, &p.DeliveryType, &p.Instructions, &p.Stock, &p.IsActive, &p.CreatedAt, &p.CreatedBy,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// UpdateStock updates the stock for a given reward.
|
||||
func (r *postgresRewardRepository) UpdateStock(ctx context.Context, id int, quantity int) error {
|
||||
query := `UPDATE rewards SET stock = stock - $1 WHERE id = $2 AND stock != -1` // -1 is infinite stock
|
||||
querier := getQuerier(ctx, r.db)
|
||||
_, err := querier.Exec(ctx, query, quantity, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// Update updates an existing reward
|
||||
func (r *postgresRewardRepository) Update(ctx context.Context, id int, reward *models.Reward) (*models.Reward, error) {
|
||||
query := `
|
||||
UPDATE rewards
|
||||
SET title = $2, description = $3, image_url = $4, price_stars = $5,
|
||||
delivery_type = $6, instructions = $7, stock = $8, is_active = $9
|
||||
WHERE id = $1
|
||||
RETURNING id, title, description, image_url, price_stars, delivery_type, instructions, stock, is_active, created_at, created_by
|
||||
`
|
||||
|
||||
var p models.Reward
|
||||
err := r.db.QueryRow(ctx, query,
|
||||
id, reward.Title, reward.Description, reward.ImageURL, reward.PriceStars,
|
||||
reward.DeliveryType, reward.Instructions, reward.Stock, reward.IsActive,
|
||||
).Scan(
|
||||
&p.ID, &p.Title, &p.Description, &p.ImageURL, &p.PriceStars,
|
||||
&p.DeliveryType, &p.Instructions, &p.Stock, &p.IsActive, &p.CreatedAt, &p.CreatedBy,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// Delete deletes a reward
|
||||
func (r *postgresRewardRepository) Delete(ctx context.Context, id int) error {
|
||||
query := `DELETE FROM rewards WHERE id = $1`
|
||||
_, err := r.db.Exec(ctx, query, id)
|
||||
return err
|
||||
}
|
||||
60
backend/internal/repository/user_repository.go
Normal file
60
backend/internal/repository/user_repository.go
Normal file
@ -0,0 +1,60 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sno/internal/models"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// UserRepository defines the interface for user data operations.
|
||||
type UserRepository interface {
|
||||
GetByID(ctx context.Context, telegramID int64) (*models.User, error)
|
||||
CreateUser(ctx context.Context, user *models.User) error
|
||||
UpdateStarsBalance(ctx context.Context, userID int64, amount int) error
|
||||
GetDB() *pgxpool.Pool
|
||||
}
|
||||
|
||||
// postgresUserRepository implements the UserRepository interface.
|
||||
type postgresUserRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewUserRepository creates a new instance of a user repository.
|
||||
func NewUserRepository(db *pgxpool.Pool) UserRepository {
|
||||
return &postgresUserRepository{db: db}
|
||||
}
|
||||
|
||||
// GetByID retrieves a single user by their Telegram ID.
|
||||
func (r *postgresUserRepository) GetByID(ctx context.Context, telegramID int64) (*models.User, error) {
|
||||
query := `SELECT telegram_id, username, first_name, last_name, photo_url, stars_balance, created_at FROM users WHERE telegram_id = $1`
|
||||
var u models.User
|
||||
err := r.db.QueryRow(ctx, query, telegramID).Scan(
|
||||
&u.TelegramID, &u.Username, &u.FirstName, &u.LastName, &u.PhotoURL, &u.StarsBalance, &u.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// UpdateStarsBalance adds (or removes, if negative) a certain amount of stars to the user's balance.
|
||||
func (r *postgresUserRepository) UpdateStarsBalance(ctx context.Context, userID int64, amount int) error {
|
||||
query := `UPDATE users SET stars_balance = stars_balance + $1 WHERE telegram_id = $2`
|
||||
querier := getQuerier(ctx, r.db)
|
||||
_, err := querier.Exec(ctx, query, amount, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateUser creates a new user in the database
|
||||
func (r *postgresUserRepository) CreateUser(ctx context.Context, user *models.User) error {
|
||||
query := `INSERT INTO users (telegram_id, username, first_name, last_name, photo_url, stars_balance) VALUES ($1, $2, $3, $4, $5, $6)`
|
||||
querier := getQuerier(ctx, r.db)
|
||||
_, err := querier.Exec(ctx, query, user.TelegramID, user.Username, user.FirstName, user.LastName, user.PhotoURL, user.StarsBalance)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetDB returns the database connection pool
|
||||
func (r *postgresUserRepository) GetDB() *pgxpool.Pool {
|
||||
return r.db
|
||||
}
|
||||
103
backend/internal/routes/routes.go
Normal file
103
backend/internal/routes/routes.go
Normal file
@ -0,0 +1,103 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"sno/internal/handlers"
|
||||
"sno/internal/middleware"
|
||||
"sno/internal/service"
|
||||
"sno/internal/types"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
)
|
||||
|
||||
func Setup(app *fiber.App, quizHandler *handlers.QuizHandler, questionHandler *handlers.QuestionHandler, rewardHandler *handlers.RewardHandler, userHandler *handlers.UserHandler, adminHandler *handlers.AdminHandler, qrHandler *handlers.QRHandler, authHandler *handlers.AuthHandler, adminService service.AdminService, botToken string) {
|
||||
// General middleware
|
||||
app.Use(logger.New())
|
||||
|
||||
// CORS middleware - must come before auth middleware to handle preflight requests
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: "*",
|
||||
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
|
||||
AllowHeaders: "Origin,Content-Type,Accept,Authorization,X-Telegram-Init-Data,X-Telegram-WebApp-Init-Data",
|
||||
AllowCredentials: false,
|
||||
ExposeHeaders: "Content-Length",
|
||||
MaxAge: 86400, // Pre-flight request cache duration (24 hours)
|
||||
}))
|
||||
|
||||
// Group API routes
|
||||
api := app.Group("/api")
|
||||
|
||||
// --- Auth Routes ---
|
||||
api.Post("/auth/validate", authHandler.Validate) // Validates Telegram initData without middleware
|
||||
auth := api.Group("/auth")
|
||||
auth.Use(middleware.AuthMiddleware(middleware.AuthConfig{BotToken: botToken}))
|
||||
auth.Get("/me", authHandler.GetMe) // Get current authenticated user
|
||||
auth.Get("/admin-role", authHandler.GetAdminRole) // Get admin role for current user
|
||||
|
||||
// --- Protected Routes (require auth) ---
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.AuthMiddleware(middleware.AuthConfig{BotToken: botToken}))
|
||||
|
||||
// --- User Routes ---
|
||||
protected.Get("/me", userHandler.GetMe) // Get current user profile
|
||||
user := protected.Group("/user")
|
||||
user.Get("/transactions", userHandler.GetUserTransactions)
|
||||
user.Get("/purchases", userHandler.GetUserPurchases)
|
||||
|
||||
// --- Quiz Routes ---
|
||||
quiz := protected.Group("/quizzes")
|
||||
quiz.Get("/", quizHandler.GetAllQuizzes)
|
||||
quiz.Get("/:id", quizHandler.GetQuizByID)
|
||||
quiz.Post("/:id/submit", quizHandler.SubmitQuiz)
|
||||
quiz.Get("/:id/can-repeat", quizHandler.CanRepeat)
|
||||
|
||||
// --- Reward (Shop) Routes ---
|
||||
reward := protected.Group("/rewards")
|
||||
reward.Get("/", rewardHandler.GetAllRewards)
|
||||
reward.Post("/:id/purchase", rewardHandler.PurchaseReward)
|
||||
|
||||
// --- QR & Deep Link Routes ---
|
||||
qr := protected.Group("/qr")
|
||||
qr.Post("/validate", qrHandler.Validate)
|
||||
|
||||
// --- Admin Routes (with role-based middleware) ---
|
||||
admin := api.Group("/admin")
|
||||
|
||||
// Admin middleware configuration
|
||||
adminConfig := middleware.AdminConfig{
|
||||
AdminService: adminService, // Need to pass this from main
|
||||
}
|
||||
|
||||
// Admin routes requiring any admin role (admin or operator)
|
||||
adminAnyRole := admin.Group("")
|
||||
adminAnyRole.Use(middleware.WithAdminRoles(adminConfig, types.RoleAdmin, types.RoleOperator))
|
||||
|
||||
// Admin routes requiring admin role only
|
||||
adminOnly := admin.Group("")
|
||||
adminOnly.Use(middleware.WithAdminRoles(adminConfig, types.RoleAdmin))
|
||||
|
||||
// Admin: Quizzes (operator+)
|
||||
adminAnyRole.Post("/quizzes", quizHandler.CreateQuiz)
|
||||
adminAnyRole.Post("/quizzes/:quiz_id/questions", questionHandler.CreateQuestion)
|
||||
adminAnyRole.Put("/quizzes/:quiz_id/questions/:question_id", questionHandler.UpdateQuestion)
|
||||
adminAnyRole.Delete("/quizzes/:quiz_id/questions/:question_id", questionHandler.DeleteQuestion)
|
||||
adminOnly.Put("/quizzes/:id", quizHandler.UpdateQuiz)
|
||||
adminOnly.Delete("/quizzes/:id", quizHandler.DeleteQuiz)
|
||||
|
||||
// Admin: Rewards (operator+)
|
||||
adminAnyRole.Post("/rewards", rewardHandler.CreateReward)
|
||||
adminOnly.Put("/rewards/:id", rewardHandler.UpdateReward)
|
||||
adminOnly.Delete("/rewards/:id", rewardHandler.DeleteReward)
|
||||
|
||||
// Admin: Users & Balance (admin only)
|
||||
adminOnly.Post("/users/grant-stars", adminHandler.GrantStars)
|
||||
adminAnyRole.Post("/qrcodes", adminHandler.GenerateQRCodes)
|
||||
|
||||
// Admin: Operators (admin only)
|
||||
adminOnly.Post("/operators", adminHandler.CreateOperator)
|
||||
adminOnly.Delete("/operators/:id", adminHandler.DeleteOperator)
|
||||
|
||||
// Admin: Analytics (operator+)
|
||||
adminAnyRole.Get("/analytics", adminHandler.GetAnalytics)
|
||||
}
|
||||
192
backend/internal/service/admin_service.go
Normal file
192
backend/internal/service/admin_service.go
Normal file
@ -0,0 +1,192 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sno/internal/models"
|
||||
"sno/internal/repository"
|
||||
"sno/internal/types"
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// AdminService defines the interface for admin-related business logic.
|
||||
type AdminService interface {
|
||||
GrantStars(ctx context.Context, userID int64, amount int) error
|
||||
GenerateUniqueQRCodes(ctx context.Context, qrType, qrValue string, count int) ([]string, error)
|
||||
GetUserRole(ctx context.Context, userID int64) (types.UserRole, error)
|
||||
CreateOperator(ctx context.Context, telegramID int64, name string) error
|
||||
DeleteOperator(ctx context.Context, telegramID int64) error
|
||||
GetAnalytics(ctx context.Context) (*models.Analytics, error)
|
||||
}
|
||||
|
||||
// adminService implements the AdminService interface.
|
||||
type adminService struct {
|
||||
userRepo repository.UserRepository
|
||||
adminRepo repository.AdminRepository
|
||||
redisClient *redis.Client
|
||||
}
|
||||
|
||||
// NewAdminService creates a new instance of an admin service.
|
||||
func NewAdminService(userRepo repository.UserRepository, adminRepo repository.AdminRepository, redisClient *redis.Client) AdminService {
|
||||
return &adminService{
|
||||
userRepo: userRepo,
|
||||
adminRepo: adminRepo,
|
||||
redisClient: redisClient,
|
||||
}
|
||||
}
|
||||
|
||||
// GrantStars adds or removes stars from a user's balance.
|
||||
func (s *adminService) GrantStars(ctx context.Context, userID int64, amount int) error {
|
||||
if amount == 0 {
|
||||
return errors.New("amount cannot be zero")
|
||||
}
|
||||
// We might want to check if the user exists first, but the UPDATE query
|
||||
// will just do nothing if the user doesn't exist, which is safe.
|
||||
return s.userRepo.UpdateStarsBalance(ctx, userID, amount)
|
||||
}
|
||||
|
||||
// GenerateUniqueQRCodes generates unique QR codes and stores them in Redis.
|
||||
// The format in Redis will be a SET named `unique_qrs:<type>:<value>`
|
||||
// Each member of the set will be a UUID.
|
||||
func (s *adminService) GenerateUniqueQRCodes(ctx context.Context, qrType, qrValue string, count int) ([]string, error) {
|
||||
if count <= 0 {
|
||||
return nil, errors.New("count must be positive")
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("unique_qrs:%s:%s", qrType, qrValue)
|
||||
codes := make([]string, count)
|
||||
members := make([]interface{}, count)
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
newUUID := uuid.New().String()
|
||||
codes[i] = newUUID
|
||||
members[i] = newUUID
|
||||
}
|
||||
|
||||
// Add all generated UUIDs to the Redis set
|
||||
if err := s.redisClient.SAdd(ctx, key, members...).Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to add unique QR codes to Redis: %w", err)
|
||||
}
|
||||
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
// GetUserRole retrieves the user's role from the admins table
|
||||
func (s *adminService) GetUserRole(ctx context.Context, userID int64) (types.UserRole, error) {
|
||||
return s.adminRepo.GetUserRole(ctx, userID)
|
||||
}
|
||||
|
||||
// CreateOperator creates a new operator with the given telegram ID and name
|
||||
func (s *adminService) CreateOperator(ctx context.Context, telegramID int64, name string) error {
|
||||
// Check if user already exists as admin
|
||||
existingAdmin, err := s.adminRepo.GetByTelegramID(ctx, telegramID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check existing admin: %w", err)
|
||||
}
|
||||
if existingAdmin != nil {
|
||||
return errors.New("user is already an admin or operator")
|
||||
}
|
||||
|
||||
// Create new operator
|
||||
admin := &models.Admin{
|
||||
TelegramID: telegramID,
|
||||
Role: models.RoleOperator,
|
||||
Name: &name,
|
||||
}
|
||||
|
||||
return s.adminRepo.Create(ctx, admin)
|
||||
}
|
||||
|
||||
// DeleteOperator deletes an operator by their telegram ID
|
||||
func (s *adminService) DeleteOperator(ctx context.Context, telegramID int64) error {
|
||||
// Check if user exists and is an operator
|
||||
existingAdmin, err := s.adminRepo.GetByTelegramID(ctx, telegramID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check existing admin: %w", err)
|
||||
}
|
||||
if existingAdmin == nil {
|
||||
return errors.New("operator not found")
|
||||
}
|
||||
if existingAdmin.Role != models.RoleOperator {
|
||||
return errors.New("user is not an operator")
|
||||
}
|
||||
|
||||
// Delete the operator
|
||||
return s.adminRepo.Delete(ctx, telegramID)
|
||||
}
|
||||
|
||||
// GetAnalytics retrieves various statistics and metrics for the admin dashboard
|
||||
func (s *adminService) GetAnalytics(ctx context.Context) (*models.Analytics, error) {
|
||||
analytics := &models.Analytics{}
|
||||
|
||||
// Query all analytics data in a single transaction for consistency
|
||||
tx, err := s.userRepo.GetDB().Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Total users
|
||||
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM users").Scan(&analytics.TotalUsers)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get total users: %w", err)
|
||||
}
|
||||
|
||||
// Total quizzes
|
||||
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM quizzes").Scan(&analytics.TotalQuizzes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get total quizzes: %w", err)
|
||||
}
|
||||
|
||||
// Active quizzes
|
||||
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM quizzes WHERE is_active = true").Scan(&analytics.ActiveQuizzes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get active quizzes: %w", err)
|
||||
}
|
||||
|
||||
// Total rewards
|
||||
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM rewards").Scan(&analytics.TotalRewards)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get total rewards: %w", err)
|
||||
}
|
||||
|
||||
// Active rewards
|
||||
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM rewards WHERE is_active = true").Scan(&analytics.ActiveRewards)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get active rewards: %w", err)
|
||||
}
|
||||
|
||||
// Total quiz attempts
|
||||
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM quiz_attempts").Scan(&analytics.TotalAttempts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get total quiz attempts: %w", err)
|
||||
}
|
||||
|
||||
// Total purchases
|
||||
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM purchases").Scan(&analytics.TotalPurchases)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get total purchases: %w", err)
|
||||
}
|
||||
|
||||
// Total QR scans
|
||||
err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM qr_scans").Scan(&analytics.TotalQRScans)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get total QR scans: %w", err)
|
||||
}
|
||||
|
||||
// Stars distributed (from quiz attempts)
|
||||
err = tx.QueryRow(ctx, "SELECT COALESCE(SUM(stars_earned), 0) FROM quiz_attempts").Scan(&analytics.StarsDistributed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get stars distributed: %w", err)
|
||||
}
|
||||
|
||||
// Stars spent (from purchases)
|
||||
err = tx.QueryRow(ctx, "SELECT COALESCE(SUM(stars_spent), 0) FROM purchases").Scan(&analytics.StarsSpent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get stars spent: %w", err)
|
||||
}
|
||||
|
||||
return analytics, nil
|
||||
}
|
||||
192
backend/internal/service/qr_service.go
Normal file
192
backend/internal/service/qr_service.go
Normal file
@ -0,0 +1,192 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sno/internal/models"
|
||||
"sno/internal/repository"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// QRService defines the interface for QR code processing logic.
|
||||
type QRService interface {
|
||||
ValidatePayload(ctx context.Context, userID int64, payload string) (*models.QRValidateResponse, error)
|
||||
GenerateUniqueToken(ctx context.Context, qrType string, value string) (string, error)
|
||||
ValidateUniqueToken(ctx context.Context, token string) (*models.QRTokenData, error)
|
||||
}
|
||||
|
||||
// qrService implements the QRService interface.
|
||||
type qrService struct {
|
||||
qrScanRepo repository.QRScanRepository
|
||||
adminSvc AdminService
|
||||
quizSvc QuizService
|
||||
redis *redis.Client
|
||||
}
|
||||
|
||||
// NewQRService creates a new instance of a QR service.
|
||||
func NewQRService(qrRepo repository.QRScanRepository, adminSvc AdminService, quizSvc QuizService, redisClient *redis.Client) QRService {
|
||||
return &qrService{
|
||||
qrScanRepo: qrRepo,
|
||||
adminSvc: adminSvc,
|
||||
quizSvc: quizSvc,
|
||||
redis: redisClient,
|
||||
}
|
||||
}
|
||||
|
||||
// generateRandomToken generates a secure random token
|
||||
func (s *qrService) generateRandomToken() (string, error) {
|
||||
b := make([]byte, 16) // 128 bits
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", b), nil
|
||||
}
|
||||
|
||||
// GenerateUniqueToken creates a new unique QR token and stores it in Redis
|
||||
func (s *qrService) GenerateUniqueToken(ctx context.Context, qrType string, value string) (string, error) {
|
||||
token, err := s.generateRandomToken()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate token: %w", err)
|
||||
}
|
||||
|
||||
tokenData := &models.QRTokenData{
|
||||
Type: qrType,
|
||||
Value: value,
|
||||
Used: false,
|
||||
}
|
||||
|
||||
// Store in Redis with 30 day expiration
|
||||
data, err := json.Marshal(tokenData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal token data: %w", err)
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("qr_token:%s", token)
|
||||
err = s.redis.Set(ctx, key, data, 30*24*time.Hour).Err()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to store token in Redis: %w", err)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ValidateUniqueToken validates a QR token and marks it as used
|
||||
func (s *qrService) ValidateUniqueToken(ctx context.Context, token string) (*models.QRTokenData, error) {
|
||||
key := fmt.Sprintf("qr_token:%s", token)
|
||||
|
||||
// Get token data from Redis
|
||||
data, err := s.redis.Get(ctx, key).Bytes()
|
||||
if err != nil {
|
||||
if err == redis.Nil {
|
||||
return nil, errors.New("invalid or expired token")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get token from Redis: %w", err)
|
||||
}
|
||||
|
||||
var tokenData models.QRTokenData
|
||||
if err := json.Unmarshal(data, &tokenData); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal token data: %w", err)
|
||||
}
|
||||
|
||||
// Check if token is already used
|
||||
if tokenData.Used {
|
||||
return nil, errors.New("token has already been used")
|
||||
}
|
||||
|
||||
// Mark token as used
|
||||
tokenData.Used = true
|
||||
updatedData, err := json.Marshal(tokenData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal updated token data: %w", err)
|
||||
}
|
||||
|
||||
err = s.redis.Set(ctx, key, updatedData, 30*24*time.Hour).Err()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update token in Redis: %w", err)
|
||||
}
|
||||
|
||||
return &tokenData, nil
|
||||
}
|
||||
|
||||
// ValidatePayload validates a QR token and performs the corresponding action.
|
||||
func (s *qrService) ValidatePayload(ctx context.Context, userID int64, payload string) (*models.QRValidateResponse, error) {
|
||||
// Validate as unique token
|
||||
tokenData, err := s.ValidateUniqueToken(ctx, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Process the validated token data
|
||||
return s.processTokenData(ctx, userID, tokenData, payload)
|
||||
}
|
||||
|
||||
// processTokenData processes validated token data and returns response
|
||||
func (s *qrService) processTokenData(ctx context.Context, userID int64, tokenData *models.QRTokenData, payload string) (*models.QRValidateResponse, error) {
|
||||
scanLog := &models.QRScan{
|
||||
UserID: userID,
|
||||
Source: models.InApp,
|
||||
Value: &payload,
|
||||
}
|
||||
|
||||
switch tokenData.Type {
|
||||
case "reward":
|
||||
scanLog.Type = models.QRReward
|
||||
amount, err := strconv.Atoi(tokenData.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid reward amount: %s", tokenData.Value)
|
||||
}
|
||||
|
||||
if err := s.adminSvc.GrantStars(ctx, userID, amount); err != nil {
|
||||
return nil, fmt.Errorf("failed to grant reward: %w", err)
|
||||
}
|
||||
|
||||
// Log the successful scan
|
||||
_, _ = s.qrScanRepo.Create(ctx, scanLog)
|
||||
|
||||
return &models.QRValidateResponse{
|
||||
Type: "REWARD",
|
||||
Data: map[string]int{"amount": amount},
|
||||
}, nil
|
||||
|
||||
case "quiz":
|
||||
scanLog.Type = models.QRQuiz
|
||||
quizID, err := strconv.Atoi(tokenData.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid quiz ID: %s", tokenData.Value)
|
||||
}
|
||||
|
||||
quiz, err := s.quizSvc.GetQuizByID(ctx, quizID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get quiz: %w", err)
|
||||
}
|
||||
|
||||
// Log the successful scan
|
||||
_, _ = s.qrScanRepo.Create(ctx, scanLog)
|
||||
|
||||
return &models.QRValidateResponse{
|
||||
Type: "OPEN_QUIZ",
|
||||
Data: quiz,
|
||||
}, nil
|
||||
|
||||
case "shop":
|
||||
scanLog.Type = models.QRShop
|
||||
// Shop QR codes can be used for various shop-related actions
|
||||
// For now, just log the scan and return success
|
||||
_, _ = s.qrScanRepo.Create(ctx, scanLog)
|
||||
|
||||
return &models.QRValidateResponse{
|
||||
Type: "SHOP_ACTION",
|
||||
Data: map[string]string{"action": tokenData.Value},
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown token type: %s", tokenData.Type)
|
||||
}
|
||||
}
|
||||
90
backend/internal/service/question_service.go
Normal file
90
backend/internal/service/question_service.go
Normal file
@ -0,0 +1,90 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sno/internal/models"
|
||||
"sno/internal/repository"
|
||||
)
|
||||
|
||||
// QuestionService defines the interface for question-related business logic.
|
||||
type QuestionService interface {
|
||||
CreateQuestion(ctx context.Context, question *models.Question) (*models.Question, error)
|
||||
GetQuestionByID(ctx context.Context, id int) (*models.Question, error)
|
||||
UpdateQuestion(ctx context.Context, id int, question *models.Question) (*models.Question, error)
|
||||
DeleteQuestion(ctx context.Context, id int) error
|
||||
}
|
||||
|
||||
// questionService implements the QuestionService interface.
|
||||
type questionService struct {
|
||||
questionRepo repository.QuestionRepository
|
||||
}
|
||||
|
||||
// NewQuestionService creates a new instance of a question service.
|
||||
func NewQuestionService(questionRepo repository.QuestionRepository) QuestionService {
|
||||
return &questionService{questionRepo: questionRepo}
|
||||
}
|
||||
|
||||
// CreateQuestion handles the business logic for creating a new question.
|
||||
func (s *questionService) CreateQuestion(ctx context.Context, question *models.Question) (*models.Question, error) {
|
||||
// Basic validation
|
||||
if question.Text == "" {
|
||||
return nil, errors.New("question text cannot be empty")
|
||||
}
|
||||
if len(question.Options) < 2 {
|
||||
return nil, errors.New("question must have at least two options")
|
||||
}
|
||||
|
||||
correctAnswers := 0
|
||||
for _, opt := range question.Options {
|
||||
if opt.IsCorrect {
|
||||
correctAnswers++
|
||||
}
|
||||
}
|
||||
|
||||
if correctAnswers == 0 {
|
||||
return nil, errors.New("question must have at least one correct answer")
|
||||
}
|
||||
if question.Type == models.Single && correctAnswers > 1 {
|
||||
return nil, errors.New("single choice question cannot have multiple correct answers")
|
||||
}
|
||||
|
||||
return s.questionRepo.Create(ctx, question)
|
||||
}
|
||||
|
||||
// GetQuestionByID retrieves a question by its ID.
|
||||
func (s *questionService) GetQuestionByID(ctx context.Context, id int) (*models.Question, error) {
|
||||
return s.questionRepo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// UpdateQuestion handles the business logic for updating an existing question.
|
||||
func (s *questionService) UpdateQuestion(ctx context.Context, id int, question *models.Question) (*models.Question, error) {
|
||||
// Basic validation
|
||||
if question.Text == "" {
|
||||
return nil, errors.New("question text cannot be empty")
|
||||
}
|
||||
if len(question.Options) < 2 {
|
||||
return nil, errors.New("question must have at least two options")
|
||||
}
|
||||
|
||||
correctAnswers := 0
|
||||
for _, opt := range question.Options {
|
||||
if opt.IsCorrect {
|
||||
correctAnswers++
|
||||
}
|
||||
}
|
||||
|
||||
if correctAnswers == 0 {
|
||||
return nil, errors.New("question must have at least one correct answer")
|
||||
}
|
||||
if question.Type == models.Single && correctAnswers > 1 {
|
||||
return nil, errors.New("single choice question cannot have multiple correct answers")
|
||||
}
|
||||
|
||||
return s.questionRepo.Update(ctx, id, question)
|
||||
}
|
||||
|
||||
// DeleteQuestion handles the business logic for deleting a question.
|
||||
func (s *questionService) DeleteQuestion(ctx context.Context, id int) error {
|
||||
return s.questionRepo.Delete(ctx, id)
|
||||
}
|
||||
244
backend/internal/service/quiz_service.go
Normal file
244
backend/internal/service/quiz_service.go
Normal file
@ -0,0 +1,244 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sno/internal/models"
|
||||
"sno/internal/repository"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
// QuizService defines the interface for quiz-related business logic.
|
||||
type QuizService interface {
|
||||
ListActiveQuizzes(ctx context.Context) ([]models.Quiz, error)
|
||||
CreateQuiz(ctx context.Context, quiz *models.Quiz) (*models.Quiz, error)
|
||||
GetQuizByID(ctx context.Context, id int) (*models.Quiz, error)
|
||||
UpdateQuiz(ctx context.Context, id int, quiz *models.Quiz) (*models.Quiz, error)
|
||||
DeleteQuiz(ctx context.Context, id int) error
|
||||
SubmitQuiz(ctx context.Context, userID int64, quizID int, submission models.SubmissionRequest) (*models.QuizAttempt, error)
|
||||
CanUserRepeatQuiz(ctx context.Context, userID int64, quizID int) (*models.CanRepeatResponse, error)
|
||||
}
|
||||
|
||||
// quizService implements the QuizService interface.
|
||||
type quizService struct {
|
||||
db *pgxpool.Pool // For transactions
|
||||
quizRepo repository.QuizRepository
|
||||
questionRepo repository.QuestionRepository
|
||||
userRepo repository.UserRepository
|
||||
userService UserService
|
||||
quizAttemptRepo repository.QuizAttemptRepository
|
||||
}
|
||||
|
||||
// NewQuizService creates a new instance of a quiz service.
|
||||
func NewQuizService(db *pgxpool.Pool, qr repository.QuizRepository, questionr repository.QuestionRepository, ur repository.UserRepository, us UserService, qar repository.QuizAttemptRepository) QuizService {
|
||||
return &quizService{
|
||||
db: db,
|
||||
quizRepo: qr,
|
||||
questionRepo: questionr,
|
||||
userRepo: ur,
|
||||
userService: us,
|
||||
quizAttemptRepo: qar,
|
||||
}
|
||||
}
|
||||
|
||||
// GetQuizByID retrieves a quiz and all its associated questions.
|
||||
func (s *quizService) GetQuizByID(ctx context.Context, id int) (*models.Quiz, error) {
|
||||
quiz, err := s.quizRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
questions, err := s.questionRepo.GetByQuizID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quiz.Questions = questions
|
||||
return quiz, nil
|
||||
}
|
||||
|
||||
// SubmitQuiz validates answers, calculates score, and awards stars.
|
||||
func (s *quizService) SubmitQuiz(ctx context.Context, userID int64, quizID int, submission models.SubmissionRequest) (*models.QuizAttempt, error) {
|
||||
quiz, err := s.GetQuizByID(ctx, quizID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get quiz for submission: %w", err)
|
||||
}
|
||||
|
||||
// Ensure user exists before proceeding
|
||||
_, err = s.userService.GetUserProfile(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user profile: %w", err)
|
||||
}
|
||||
|
||||
if len(quiz.Questions) == 0 {
|
||||
return nil, errors.New("cannot submit to a quiz with no questions")
|
||||
}
|
||||
|
||||
// --- Submission Cooldown/Uniqueness Validation ---
|
||||
lastAttempt, err := s.quizAttemptRepo.GetLatestByUserIDAndQuizID(ctx, userID, quizID)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("failed to check previous attempts: %w", err)
|
||||
}
|
||||
|
||||
if lastAttempt != nil {
|
||||
if !quiz.CanRepeat {
|
||||
return nil, errors.New("quiz has already been completed")
|
||||
}
|
||||
if quiz.RepeatCooldownHours != nil {
|
||||
cooldown := time.Duration(*quiz.RepeatCooldownHours) * time.Hour
|
||||
if time.Since(lastAttempt.CompletedAt) < cooldown {
|
||||
return nil, fmt.Errorf("quiz is on cooldown, please try again later")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Scoring Logic ---
|
||||
score := 0
|
||||
correctAnswers := make(map[int][]int) // questionID -> sorted list of correct optionIDs
|
||||
for _, q := range quiz.Questions {
|
||||
for _, o := range q.Options {
|
||||
if o.IsCorrect {
|
||||
correctAnswers[q.ID] = append(correctAnswers[q.ID], o.ID)
|
||||
}
|
||||
}
|
||||
slices.Sort(correctAnswers[q.ID])
|
||||
}
|
||||
|
||||
for _, userAnswer := range submission.Answers {
|
||||
correct, exists := correctAnswers[userAnswer.QuestionID]
|
||||
if !exists {
|
||||
continue // User answered a question not in the quiz, ignore it.
|
||||
}
|
||||
slices.Sort(userAnswer.OptionIDs)
|
||||
if slices.Equal(correct, userAnswer.OptionIDs) {
|
||||
score++
|
||||
}
|
||||
}
|
||||
|
||||
starsEarned := 0
|
||||
if len(quiz.Questions) > 0 {
|
||||
starsEarned = int(float64(score) / float64(len(quiz.Questions)) * float64(quiz.RewardStars))
|
||||
}
|
||||
|
||||
// --- Database Transaction ---
|
||||
attempt := &models.QuizAttempt{
|
||||
UserID: userID,
|
||||
QuizID: quizID,
|
||||
Score: score,
|
||||
StarsEarned: starsEarned,
|
||||
Answers: submission.Answers,
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx) // Rollback is a no-op if tx is committed
|
||||
|
||||
// Create attempt record
|
||||
if _, err := s.quizAttemptRepo.Create(context.WithValue(ctx, "tx", tx), attempt); err != nil {
|
||||
return nil, fmt.Errorf("failed to create quiz attempt: %w", err)
|
||||
}
|
||||
|
||||
// Update user balance
|
||||
if starsEarned > 0 {
|
||||
if err := s.userRepo.UpdateStarsBalance(context.WithValue(ctx, "tx", tx), userID, starsEarned); err != nil {
|
||||
return nil, fmt.Errorf("failed to update user balance: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return attempt, nil
|
||||
}
|
||||
|
||||
// ListActiveQuizzes and CreateQuiz remain the same for now
|
||||
func (s *quizService) ListActiveQuizzes(ctx context.Context) ([]models.Quiz, error) {
|
||||
return s.quizRepo.GetAllActive(ctx)
|
||||
}
|
||||
func (s *quizService) CreateQuiz(ctx context.Context, quiz *models.Quiz) (*models.Quiz, error) {
|
||||
return s.quizRepo.Create(ctx, quiz)
|
||||
}
|
||||
|
||||
// CanUserRepeatQuiz checks if a user is allowed to repeat a specific quiz.
|
||||
func (s *quizService) CanUserRepeatQuiz(ctx context.Context, userID int64, quizID int) (*models.CanRepeatResponse, error) {
|
||||
quiz, err := s.quizRepo.GetByID(ctx, quizID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get quiz: %w", err)
|
||||
}
|
||||
|
||||
lastAttempt, err := s.quizAttemptRepo.GetLatestByUserIDAndQuizID(ctx, userID, quizID)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("failed to check previous attempts: %w", err)
|
||||
}
|
||||
|
||||
// No previous attempt, so user can take the quiz
|
||||
if lastAttempt == nil {
|
||||
return &models.CanRepeatResponse{CanRepeat: true}, nil
|
||||
}
|
||||
|
||||
// Quiz is marked as non-repeatable
|
||||
if !quiz.CanRepeat {
|
||||
return &models.CanRepeatResponse{CanRepeat: false}, nil
|
||||
}
|
||||
|
||||
// Quiz is repeatable, check cooldown
|
||||
if quiz.RepeatCooldownHours != nil {
|
||||
cooldown := time.Duration(*quiz.RepeatCooldownHours) * time.Hour
|
||||
nextAvailableAt := lastAttempt.CompletedAt.Add(cooldown)
|
||||
if time.Now().Before(nextAvailableAt) {
|
||||
return &models.CanRepeatResponse{
|
||||
CanRepeat: false,
|
||||
NextAvailableAt: &nextAvailableAt,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Cooldown has passed or doesn't exist
|
||||
return &models.CanRepeatResponse{CanRepeat: true}, nil
|
||||
}
|
||||
|
||||
// UpdateQuiz updates an existing quiz
|
||||
func (s *quizService) UpdateQuiz(ctx context.Context, id int, quiz *models.Quiz) (*models.Quiz, error) {
|
||||
// Check if quiz exists
|
||||
existingQuiz, err := s.quizRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get existing quiz: %w", err)
|
||||
}
|
||||
if existingQuiz == nil {
|
||||
return nil, errors.New("quiz not found")
|
||||
}
|
||||
|
||||
// Update quiz
|
||||
updatedQuiz, err := s.quizRepo.Update(ctx, id, quiz)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update quiz: %w", err)
|
||||
}
|
||||
|
||||
return updatedQuiz, nil
|
||||
}
|
||||
|
||||
// DeleteQuiz deletes a quiz
|
||||
func (s *quizService) DeleteQuiz(ctx context.Context, id int) error {
|
||||
// Check if quiz exists
|
||||
existingQuiz, err := s.quizRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get existing quiz: %w", err)
|
||||
}
|
||||
if existingQuiz == nil {
|
||||
return errors.New("quiz not found")
|
||||
}
|
||||
|
||||
// Delete quiz and associated questions
|
||||
err = s.quizRepo.Delete(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete quiz: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
159
backend/internal/service/reward_service.go
Normal file
159
backend/internal/service/reward_service.go
Normal file
@ -0,0 +1,159 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sno/internal/models"
|
||||
"sno/internal/repository"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// RewardService defines the interface for reward-related business logic.
|
||||
type RewardService interface {
|
||||
CreateReward(ctx context.Context, reward *models.Reward) (*models.Reward, error)
|
||||
ListActiveRewards(ctx context.Context) ([]models.Reward, error)
|
||||
PurchaseReward(ctx context.Context, userID int64, rewardID int) (*models.Purchase, error)
|
||||
UpdateReward(ctx context.Context, id int, reward *models.Reward) (*models.Reward, error)
|
||||
DeleteReward(ctx context.Context, id int) error
|
||||
}
|
||||
|
||||
// rewardService implements the RewardService interface.
|
||||
type rewardService struct {
|
||||
db *pgxpool.Pool
|
||||
rewardRepo repository.RewardRepository
|
||||
userRepo repository.UserRepository
|
||||
purchaseRepo repository.PurchaseRepository
|
||||
}
|
||||
|
||||
// NewRewardService creates a new instance of a reward service.
|
||||
func NewRewardService(db *pgxpool.Pool, rewardRepo repository.RewardRepository, userRepo repository.UserRepository, purchaseRepo repository.PurchaseRepository) RewardService {
|
||||
return &rewardService{
|
||||
db: db,
|
||||
rewardRepo: rewardRepo,
|
||||
userRepo: userRepo,
|
||||
purchaseRepo: purchaseRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateReward handles the business logic for creating a new reward.
|
||||
func (s *rewardService) CreateReward(ctx context.Context, reward *models.Reward) (*models.Reward, error) {
|
||||
if reward.Title == "" {
|
||||
return nil, errors.New("reward title cannot be empty")
|
||||
}
|
||||
if reward.PriceStars <= 0 {
|
||||
return nil, errors.New("reward price must be positive")
|
||||
}
|
||||
return s.rewardRepo.Create(ctx, reward)
|
||||
}
|
||||
|
||||
// ListActiveRewards retrieves a list of all active rewards.
|
||||
func (s *rewardService) ListActiveRewards(ctx context.Context) ([]models.Reward, error) {
|
||||
return s.rewardRepo.GetAllActive(ctx)
|
||||
}
|
||||
|
||||
// PurchaseReward handles the logic for a user purchasing a reward.
|
||||
func (s *rewardService) PurchaseReward(ctx context.Context, userID int64, rewardID int) (*models.Purchase, error) {
|
||||
// 1. Get reward and user details
|
||||
reward, err := s.rewardRepo.GetByID(ctx, rewardID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reward not found: %w", err)
|
||||
}
|
||||
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
|
||||
// 2. Validate purchase
|
||||
if !reward.IsActive {
|
||||
return nil, errors.New("reward is not active")
|
||||
}
|
||||
if reward.Stock == 0 { // -1 is infinite
|
||||
return nil, errors.New("reward is out of stock")
|
||||
}
|
||||
if user.StarsBalance < reward.PriceStars {
|
||||
return nil, errors.New("not enough stars")
|
||||
}
|
||||
|
||||
// 3. Start transaction
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to begin transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
txCtx := context.WithValue(ctx, "tx", tx)
|
||||
|
||||
// 4. Perform operations
|
||||
// a. Create purchase record
|
||||
purchase := &models.Purchase{
|
||||
UserID: userID,
|
||||
RewardID: rewardID,
|
||||
StarsSpent: reward.PriceStars,
|
||||
Status: models.Pending,
|
||||
}
|
||||
createdPurchase, err := s.purchaseRepo.Create(txCtx, purchase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create purchase record: %w", err)
|
||||
}
|
||||
|
||||
// b. Decrement user stars
|
||||
if err := s.userRepo.UpdateStarsBalance(txCtx, userID, -reward.PriceStars); err != nil {
|
||||
return nil, fmt.Errorf("failed to update user balance: %w", err)
|
||||
}
|
||||
|
||||
// c. Decrement stock (if not infinite)
|
||||
if reward.Stock != -1 {
|
||||
if err := s.rewardRepo.UpdateStock(txCtx, rewardID, 1); err != nil {
|
||||
return nil, fmt.Errorf("failed to update reward stock: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Commit transaction
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return createdPurchase, nil
|
||||
}
|
||||
|
||||
// UpdateReward updates an existing reward
|
||||
func (s *rewardService) UpdateReward(ctx context.Context, id int, reward *models.Reward) (*models.Reward, error) {
|
||||
// Check if reward exists
|
||||
existingReward, err := s.rewardRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get existing reward: %w", err)
|
||||
}
|
||||
if existingReward == nil {
|
||||
return nil, errors.New("reward not found")
|
||||
}
|
||||
|
||||
// Update reward
|
||||
updatedReward, err := s.rewardRepo.Update(ctx, id, reward)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update reward: %w", err)
|
||||
}
|
||||
|
||||
return updatedReward, nil
|
||||
}
|
||||
|
||||
// DeleteReward deletes a reward
|
||||
func (s *rewardService) DeleteReward(ctx context.Context, id int) error {
|
||||
// Check if reward exists
|
||||
existingReward, err := s.rewardRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get existing reward: %w", err)
|
||||
}
|
||||
if existingReward == nil {
|
||||
return errors.New("reward not found")
|
||||
}
|
||||
|
||||
// Delete reward
|
||||
err = s.rewardRepo.Delete(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete reward: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
109
backend/internal/service/user_service.go
Normal file
109
backend/internal/service/user_service.go
Normal file
@ -0,0 +1,109 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sno/internal/models"
|
||||
"sno/internal/repository"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// UserService defines the interface for user-related business logic.
|
||||
type UserService interface {
|
||||
GetUserProfile(ctx context.Context, userID int64) (*models.User, error)
|
||||
GetOrCreateUser(ctx context.Context, telegramID int64, firstName, lastName, username, photoURL string) (*models.User, error)
|
||||
GetUserPurchases(ctx context.Context, userID int64) ([]models.Purchase, error)
|
||||
GetUserTransactions(ctx context.Context, userID int64) ([]models.Transaction, error)
|
||||
}
|
||||
|
||||
// userService implements the UserService interface.
|
||||
type userService struct {
|
||||
userRepo repository.UserRepository
|
||||
purchaseRepo repository.PurchaseRepository
|
||||
quizAttemptRepo repository.QuizAttemptRepository
|
||||
}
|
||||
|
||||
// NewUserService creates a new instance of a user service.
|
||||
func NewUserService(userRepo repository.UserRepository, purchaseRepo repository.PurchaseRepository, quizAttemptRepo repository.QuizAttemptRepository) UserService {
|
||||
return &userService{userRepo: userRepo, purchaseRepo: purchaseRepo, quizAttemptRepo: quizAttemptRepo}
|
||||
}
|
||||
|
||||
// GetUserProfile retrieves the user's profile.
|
||||
func (s *userService) GetUserProfile(ctx context.Context, userID int64) (*models.User, error) {
|
||||
return s.userRepo.GetByID(ctx, userID)
|
||||
}
|
||||
|
||||
// GetOrCreateUser retrieves an existing user or creates a new one
|
||||
func (s *userService) GetOrCreateUser(ctx context.Context, telegramID int64, firstName, lastName, username, photoURL string) (*models.User, error) {
|
||||
// Try to get existing user first
|
||||
user, err := s.userRepo.GetByID(ctx, telegramID)
|
||||
if err == nil {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// If user not found, create new one
|
||||
newUser := &models.User{
|
||||
TelegramID: telegramID,
|
||||
FirstName: &firstName,
|
||||
LastName: &lastName,
|
||||
Username: &username,
|
||||
StarsBalance: 0,
|
||||
}
|
||||
|
||||
// Add photo URL if provided
|
||||
if photoURL != "" {
|
||||
newUser.PhotoURL = &photoURL
|
||||
}
|
||||
|
||||
if err := s.userRepo.CreateUser(ctx, newUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newUser, nil
|
||||
}
|
||||
|
||||
// GetUserPurchases retrieves the user's purchase history.
|
||||
func (s *userService) GetUserPurchases(ctx context.Context, userID int64) ([]models.Purchase, error) {
|
||||
return s.purchaseRepo.GetByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
// GetUserTransactions retrieves a unified list of all user transactions (earned and spent).
|
||||
func (s *userService) GetUserTransactions(ctx context.Context, userID int64) ([]models.Transaction, error) {
|
||||
var transactions []models.Transaction
|
||||
|
||||
// Get purchases (spent)
|
||||
purchases, err := s.purchaseRepo.GetByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range purchases {
|
||||
transactions = append(transactions, models.Transaction{
|
||||
Type: models.TransactionSpent,
|
||||
Amount: p.StarsSpent,
|
||||
Description: "Покупка приза",
|
||||
CreatedAt: p.PurchasedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// Get quiz attempts (earned)
|
||||
attempts, err := s.quizAttemptRepo.GetByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range attempts {
|
||||
if a.StarsEarned > 0 {
|
||||
transactions = append(transactions, models.Transaction{
|
||||
Type: models.TransactionEarned,
|
||||
Amount: a.StarsEarned,
|
||||
Description: "Награда за викторину",
|
||||
CreatedAt: a.CompletedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort transactions by date descending
|
||||
sort.Slice(transactions, func(i, j int) bool {
|
||||
return transactions[i].CreatedAt.After(transactions[j].CreatedAt)
|
||||
})
|
||||
|
||||
return transactions, nil
|
||||
}
|
||||
10
backend/internal/types/roles.go
Normal file
10
backend/internal/types/roles.go
Normal file
@ -0,0 +1,10 @@
|
||||
package types
|
||||
|
||||
// UserRole represents user roles
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
RoleAdmin UserRole = "admin"
|
||||
RoleOperator UserRole = "operator"
|
||||
RoleUser UserRole = "user"
|
||||
)
|
||||
131
backend/migrations/001_init_schema.sql
Normal file
131
backend/migrations/001_init_schema.sql
Normal file
@ -0,0 +1,131 @@
|
||||
-- +goose Up
|
||||
-- Users Table
|
||||
CREATE TABLE "users" (
|
||||
"telegram_id" BIGINT PRIMARY KEY,
|
||||
"username" VARCHAR(255),
|
||||
"first_name" VARCHAR(255),
|
||||
"last_name" VARCHAR(255),
|
||||
"stars_balance" INT NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Quizzes Table
|
||||
CREATE TABLE "quizzes" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"title" VARCHAR(255) NOT NULL,
|
||||
"description" TEXT,
|
||||
"image_url" VARCHAR(255),
|
||||
"reward_stars" INT NOT NULL DEFAULT 0,
|
||||
"has_timer" BOOLEAN NOT NULL DEFAULT false,
|
||||
"timer_per_question" INT,
|
||||
"can_repeat" BOOLEAN NOT NULL DEFAULT false,
|
||||
"repeat_cooldown_hours" INT,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_by" BIGINT,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Questions Table
|
||||
CREATE TYPE question_type AS ENUM ('single', 'multiple');
|
||||
|
||||
CREATE TABLE "questions" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"quiz_id" INT NOT NULL REFERENCES "quizzes"("id") ON DELETE CASCADE,
|
||||
"text" TEXT NOT NULL,
|
||||
"type" question_type NOT NULL,
|
||||
"options" JSONB NOT NULL, -- [{"id": 1, "text": "Option A", "is_correct": true}]
|
||||
"order_index" INT NOT NULL
|
||||
);
|
||||
|
||||
-- Quiz Attempts Table
|
||||
CREATE TABLE "quiz_attempts" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"user_id" BIGINT NOT NULL REFERENCES "users"("telegram_id") ON DELETE CASCADE,
|
||||
"quiz_id" INT NOT NULL REFERENCES "quizzes"("id") ON DELETE CASCADE,
|
||||
"score" INT NOT NULL,
|
||||
"stars_earned" INT NOT NULL,
|
||||
"completed_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"answers" JSONB
|
||||
);
|
||||
|
||||
-- Rewards (Prizes) Table
|
||||
CREATE TYPE delivery_type AS ENUM ('physical', 'digital');
|
||||
|
||||
CREATE TABLE "rewards" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"title" VARCHAR(255) NOT NULL,
|
||||
"description" TEXT,
|
||||
"image_url" VARCHAR(255),
|
||||
"price_stars" INT NOT NULL,
|
||||
"delivery_type" delivery_type NOT NULL,
|
||||
"instructions" TEXT,
|
||||
"stock" INT NOT NULL DEFAULT 0, -- 0 for infinite
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_by" BIGINT,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Purchases Table
|
||||
CREATE TYPE purchase_status AS ENUM ('pending', 'delivered', 'cancelled');
|
||||
|
||||
CREATE TABLE "purchases" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"user_id" BIGINT NOT NULL REFERENCES "users"("telegram_id") ON DELETE CASCADE,
|
||||
"reward_id" INT NOT NULL REFERENCES "rewards"("id") ON DELETE CASCADE,
|
||||
"stars_spent" INT NOT NULL,
|
||||
"purchased_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"status" purchase_status NOT NULL DEFAULT 'pending'
|
||||
);
|
||||
|
||||
-- QR Scans Table
|
||||
CREATE TYPE qr_scan_type AS ENUM ('reward', 'quiz', 'shop');
|
||||
CREATE TYPE qr_scan_source AS ENUM ('in_app', 'external');
|
||||
|
||||
CREATE TABLE "qr_scans" (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"user_id" BIGINT NOT NULL REFERENCES "users"("telegram_id") ON DELETE CASCADE,
|
||||
"type" qr_scan_type NOT NULL,
|
||||
"value" VARCHAR(255),
|
||||
"scanned_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"source" qr_scan_source NOT NULL
|
||||
);
|
||||
|
||||
-- Admins & Operators Table
|
||||
CREATE TYPE admin_role AS ENUM ('admin', 'operator');
|
||||
|
||||
CREATE TABLE "admins" (
|
||||
"telegram_id" BIGINT PRIMARY KEY,
|
||||
"role" admin_role NOT NULL,
|
||||
"name" VARCHAR(255),
|
||||
"added_by" BIGINT,
|
||||
"added_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Add foreign key constraints for created_by fields
|
||||
ALTER TABLE "quizzes" ADD FOREIGN KEY ("created_by") REFERENCES "admins"("telegram_id");
|
||||
ALTER TABLE "rewards" ADD FOREIGN KEY ("created_by") REFERENCES "admins"("telegram_id");
|
||||
ALTER TABLE "admins" ADD FOREIGN KEY ("added_by") REFERENCES "admins"("telegram_id");
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX ON "quiz_attempts" ("user_id", "quiz_id");
|
||||
CREATE INDEX ON "purchases" ("user_id");
|
||||
CREATE INDEX ON "qr_scans" ("user_id");
|
||||
|
||||
-- +goose Down
|
||||
-- Drop all tables in reverse order of creation
|
||||
DROP TABLE IF EXISTS "purchases";
|
||||
DROP TABLE IF EXISTS "quiz_attempts";
|
||||
DROP TABLE IF EXISTS "questions";
|
||||
DROP TABLE IF EXISTS "rewards";
|
||||
DROP TABLE IF EXISTS "quizzes";
|
||||
DROP TABLE IF EXISTS "qr_scans";
|
||||
DROP TABLE IF EXISTS "users";
|
||||
DROP TABLE IF EXISTS "admins";
|
||||
|
||||
-- Drop custom types
|
||||
DROP TYPE IF EXISTS "question_type";
|
||||
DROP TYPE IF EXISTS "delivery_type";
|
||||
DROP TYPE IF EXISTS "purchase_status";
|
||||
DROP TYPE IF EXISTS "qr_scan_type";
|
||||
DROP TYPE IF EXISTS "qr_scan_source";
|
||||
DROP TYPE IF EXISTS "admin_role";
|
||||
7
backend/migrations/002_add_photo_url_to_users.sql
Normal file
7
backend/migrations/002_add_photo_url_to_users.sql
Normal file
@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
-- Add photo_url field to users table
|
||||
ALTER TABLE "users" ADD COLUMN "photo_url" VARCHAR(255);
|
||||
|
||||
-- +goose Down
|
||||
-- Remove photo_url field from users table
|
||||
ALTER TABLE "users" DROP COLUMN "photo_url";
|
||||
BIN
backend/server
Executable file
BIN
backend/server
Executable file
Binary file not shown.
23
bot/.env.example
Normal file
23
bot/.env.example
Normal file
@ -0,0 +1,23 @@
|
||||
# Telegram Bot Token
|
||||
BOT_TOKEN=your_bot_token_here
|
||||
|
||||
# Backend API URL
|
||||
BACKEND_API_URL=http://localhost:8080
|
||||
|
||||
# Frontend URL (for mini app)
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
|
||||
# Bot username (without @)
|
||||
BOT_USERNAME=QuizBot
|
||||
|
||||
# Admin user IDs (comma-separated)
|
||||
ADMIN_USER_IDS=123456789,987654321
|
||||
|
||||
# Operator user IDs (comma-separated)
|
||||
OPERATOR_USER_IDS=111111111,222222222
|
||||
|
||||
# Secret key for validating webhook requests (optional)
|
||||
WEBHOOK_SECRET=your_webhook_secret
|
||||
|
||||
# Webhook URL (optional)
|
||||
WEBHOOK_URL=https://your-domain.com/webhook
|
||||
28
bot/Dockerfile
Normal file
28
bot/Dockerfile
Normal file
@ -0,0 +1,28 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first to leverage Docker cache
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create non-root user
|
||||
RUN adduser --disabled-password --gecos '' appuser
|
||||
RUN chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Run the bot
|
||||
CMD ["python", "bot.py"]
|
||||
210
bot/README.md
Normal file
210
bot/README.md
Normal file
@ -0,0 +1,210 @@
|
||||
# Telegram Bot for Звёздные Викторины
|
||||
|
||||
Этот бот предоставляет доступ к Telegram Mini App "Звёздные Викторины" и управляет QR-кодами с токен-ориентированной системой безопасности.
|
||||
|
||||
## 🚀 Возможности
|
||||
|
||||
### 🎫 QR-код система
|
||||
- **Токен-ориентированная**: Уникальные токены для каждого QR-кода
|
||||
- **Безопасная**: Серверная валидация через backend API
|
||||
- **Одноразовая**: Каждый токен используется только один раз
|
||||
- **Типы QR-кодов**: reward (награды), quiz (викторины), shop (магазин)
|
||||
|
||||
### 👥 Администрирование
|
||||
- **Роли**: Администраторы и операторы
|
||||
- **Генерация QR-кодов**: Через бота с валидацией
|
||||
- **Управление**: Панель администратора в боте
|
||||
|
||||
### 🔗 Deep Links
|
||||
- **Поддержка параметров `startapp`**:
|
||||
- Начисление звёзд: `reward_X`
|
||||
- Открытие викторин: `quiz_X`
|
||||
- Переход в магазин: `shop`
|
||||
- Переход к призу: `reward_X`
|
||||
|
||||
### 🎯 Команды бота
|
||||
- `/start` - Запуск бота с параметрами
|
||||
- `/help` - Помощь
|
||||
- `/qr` - Информация о QR-кодах
|
||||
- `/admin` - Панель администратора
|
||||
|
||||
## 📦 Установка
|
||||
|
||||
1. Установите зависимости:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. Создайте файл `.env` на основе `.env.example`:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. Заполните переменные окружения в `.env`:
|
||||
```env
|
||||
BOT_TOKEN=ваш_токен_бота
|
||||
BACKEND_API_URL=http://localhost:8080
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
BOT_USERNAME=ваш_бот_юзернейм
|
||||
ADMIN_USER_IDS=123456789,987654321
|
||||
OPERATOR_USER_IDS=111111111,222222222
|
||||
```
|
||||
|
||||
## 🛠 Запуск
|
||||
|
||||
### Локальный запуск
|
||||
```bash
|
||||
python bot.py
|
||||
```
|
||||
|
||||
### С помощью Docker
|
||||
```bash
|
||||
docker build -t quiz-bot .
|
||||
docker run -p 8080:8080 quiz-bot
|
||||
```
|
||||
|
||||
### С помощью webhook
|
||||
Для продакшн-развертывания используйте webhook:
|
||||
|
||||
```python
|
||||
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
|
||||
|
||||
# Добавьте в main()
|
||||
webhook_url = "https://your-domain.com/webhook"
|
||||
await bot.set_webhook(webhook_url)
|
||||
|
||||
# Настройте aiohttp приложение
|
||||
from aiohttp import web
|
||||
app = web.Application()
|
||||
setup_application(app, dp, webhook_path="/webhook")
|
||||
```
|
||||
|
||||
## 🎫 Работа с QR-кодами
|
||||
|
||||
### Генерация QR-кодов (администраторы)
|
||||
1. Используйте команду `/admin`
|
||||
2. Выберите "Генерировать QR-коды"
|
||||
3. Выберите тип (reward/quiz/shop)
|
||||
4. Введите параметры (сумма, ID викторины и т.д.)
|
||||
5. Получите список токенов для QR-кодов
|
||||
|
||||
### Использование QR-кодов
|
||||
1. Сгенерируйте QR-коды с полученными токенами
|
||||
2. Разместите QR-коды в нужных местах
|
||||
3. Пользователи сканируют их через приложение
|
||||
4. Приложение отправляет токен на `/api/qr/validate`
|
||||
|
||||
### Пример QR-кода
|
||||
```
|
||||
Содержимое: a1b2c3d4e5f678901234567890abcdef
|
||||
```
|
||||
|
||||
## 📋 Примеры Deep Links
|
||||
|
||||
### Начисление звёзд
|
||||
```
|
||||
https://t.me/YourBot?startapp=reward_50
|
||||
https://t.me/YourBot?startapp=reward_100
|
||||
```
|
||||
|
||||
### Открытие викторины
|
||||
```
|
||||
https://t.me/YourBot?startapp=quiz_123
|
||||
https://t.me/YourBot?startapp=quiz_456
|
||||
```
|
||||
|
||||
### Переход в магазин
|
||||
```
|
||||
https://t.me/YourBot?startapp=shop
|
||||
```
|
||||
|
||||
### Переход к призу
|
||||
```
|
||||
https://t.me/YourBot?startapp=reward_789
|
||||
```
|
||||
|
||||
## 🔧 Конфигурация
|
||||
|
||||
| Переменная | Описание | Обязательна |
|
||||
|------------|----------|-------------|
|
||||
| `BOT_TOKEN` | Токен Telegram бота | ✅ |
|
||||
| `BACKEND_API_URL` | URL бэкенда | ✅ |
|
||||
| `FRONTEND_URL` | URL фронтенда | ✅ |
|
||||
| `BOT_USERNAME` | Юзернейм бота | ❌ |
|
||||
| `ADMIN_USER_IDS` | ID администраторов (через запятую) | ❌ |
|
||||
| `OPERATOR_USER_IDS` | ID операторов (через запятую) | ❌ |
|
||||
| `WEBHOOK_URL` | URL для webhook | ❌ |
|
||||
| `WEBHOOK_SECRET` | Секрет для webhook | ❌ |
|
||||
|
||||
## 📁 Структура проекта
|
||||
|
||||
```
|
||||
bot/
|
||||
├── bot.py # Основной файл бота
|
||||
├── config.py # Конфигурация
|
||||
├── handlers.py # Обработчики команд
|
||||
├── admin_handlers.py # Административные обработчики
|
||||
├── qr_service.py # Сервис для работы с QR API
|
||||
├── utils.py # Утилиты
|
||||
├── requirements.txt # Зависимости
|
||||
├── .env.example # Пример .env
|
||||
├── Dockerfile # Docker конфигурация
|
||||
├── examples.py # Примеры использования
|
||||
└── README.md # Документация
|
||||
```
|
||||
|
||||
## 🚨 Безопасность
|
||||
|
||||
### QR-коды
|
||||
- **Уникальные токены**: 128-битная энтропия
|
||||
- **Одноразовые**: После использования помечаются как использованные
|
||||
- **Срок действия**: 30 дней
|
||||
- **Серверная валидация**: Проверка через backend API
|
||||
|
||||
### Пользователи
|
||||
- **Аутентификация**: Через Telegram User ID
|
||||
- **Роли**: Администраторы и операторы
|
||||
- **Права доступа**: Разграничение по ролям
|
||||
|
||||
### Сеть
|
||||
- **Вебхуки**: Поддержка секретного ключа
|
||||
- **HTTPS**: Рекомендуется для продакшена
|
||||
- **Логирование**: Все действия записываются
|
||||
|
||||
## 📊 Логирование
|
||||
|
||||
Бот логирует важные события:
|
||||
- Запуск и остановку
|
||||
- Обработку команд
|
||||
- Генерацию QR-кодов
|
||||
- Ошибки и исключения
|
||||
- Действия администраторов
|
||||
|
||||
Уровень логирования можно настроить через переменную `LOG_LEVEL`.
|
||||
|
||||
## 🔌 Интеграция
|
||||
|
||||
### С бэкендом
|
||||
Бот интегрируется с бэкендом через API:
|
||||
- Генерация QR-токенов: `POST /api/admin/qrcodes`
|
||||
- Валидация QR-токенов: `POST /api/qr/validate`
|
||||
- Управление пользователями и викторинами
|
||||
|
||||
### С фронтендом
|
||||
Мини-приложение открывается через Web App API:
|
||||
- Автоматическая передача Telegram initData
|
||||
- Поддержка параметров `startapp`
|
||||
- Сканирование QR-кодов через камеру
|
||||
- Валидация токенов через API
|
||||
|
||||
## 🛠️ Разработка
|
||||
|
||||
### Добавление новых команд
|
||||
1. Создайте обработчик в `handlers.py` или `admin_handlers.py`
|
||||
2. Зарегистрируйте команду в `bot.py`
|
||||
3. Обновите документацию
|
||||
|
||||
### Тестирование
|
||||
- Используйте тестовые токены для разработки
|
||||
- Проверяйте права доступа для административных функций
|
||||
- Тестируйте валидацию QR-кодов через backend API
|
||||
233
bot/admin_handlers.py
Normal file
233
bot/admin_handlers.py
Normal file
@ -0,0 +1,233 @@
|
||||
"""
|
||||
Admin handlers for QR code management
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, types
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import (
|
||||
InlineKeyboardButton,
|
||||
InlineKeyboardMarkup,
|
||||
Message,
|
||||
CallbackQuery,
|
||||
)
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import config
|
||||
from qr_service import QRService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_admin_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Create admin keyboard"""
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🎫 Генерировать QR-коды", callback_data="admin_generate_qr")
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="📊 Статистика", callback_data="admin_stats")
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🔧 Настройки", callback_data="admin_settings")
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def create_qr_type_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Create QR type selection keyboard"""
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="💰 Награда", callback_data="qr_type_reward")
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🧠 Викторина", callback_data="qr_type_quiz")
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="🛒 Магазин", callback_data="qr_type_shop")
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(text="❌ Отмена", callback_data="admin_cancel")
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def create_main_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Create main keyboard with mini app button"""
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🎯 Открыть Викторины",
|
||||
web_app=types.WebAppInfo(url=config.frontend_url)
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
async def handle_admin_command(message: Message) -> None:
|
||||
"""Handle /admin command"""
|
||||
if not config.has_admin_privileges(message.from_user.id):
|
||||
await message.answer(
|
||||
"❌ У вас нет прав для доступа к панели администратора."
|
||||
)
|
||||
return
|
||||
|
||||
user_role = "Администратор" if config.is_admin(message.from_user.id) else "Оператор"
|
||||
|
||||
await message.answer(
|
||||
f"🛡️ Панель администратора\n\n"
|
||||
f"👤 Ваша роль: {user_role}\n"
|
||||
f"🆔 ID: {message.from_user.id}\n\n"
|
||||
f"Выберите действие:",
|
||||
reply_markup=create_admin_keyboard()
|
||||
)
|
||||
|
||||
|
||||
async def handle_generate_qr_callback(callback: CallbackQuery) -> None:
|
||||
"""Handle generate QR callback"""
|
||||
if not config.has_admin_privileges(callback.from_user.id):
|
||||
await callback.answer("❌ Доступ запрещен", show_alert=True)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(
|
||||
"🎫 Выберите тип QR-кода для генерации:",
|
||||
reply_markup=create_qr_type_keyboard()
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_qr_type_callback(callback: CallbackQuery) -> None:
|
||||
"""Handle QR type selection callback"""
|
||||
if not config.has_admin_privileges(callback.from_user.id):
|
||||
await callback.answer("❌ Доступ запрещен", show_alert=True)
|
||||
return
|
||||
|
||||
qr_type = callback.data.split("_")[-1]
|
||||
type_descriptions = {
|
||||
"reward": "💰 Награда (звёзды)",
|
||||
"quiz": "🧠 Викторина",
|
||||
"shop": "🛒 Магазин"
|
||||
}
|
||||
|
||||
# Store the selected type in user state
|
||||
# For simplicity, we'll use a simple approach. In production, use a proper state machine
|
||||
|
||||
instructions = {
|
||||
"reward": "Введите сумму награды в звёздах (1-1000):",
|
||||
"quiz": "Введите ID викторины:",
|
||||
"shop": "Введите действие для магазина (например: discount_10):"
|
||||
}
|
||||
|
||||
await callback.message.edit_text(
|
||||
f"🎫 {type_descriptions.get(qr_type, qr_type)}\n\n"
|
||||
f"{instructions.get(qr_type)}",
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text="❌ Отмена", callback_data="admin_cancel")]
|
||||
]
|
||||
)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_qr_generation_text(message: Message) -> None:
|
||||
"""Handle QR generation text input"""
|
||||
if not config.has_admin_privileges(message.from_user.id):
|
||||
await message.answer("❌ Доступ запрещен")
|
||||
return
|
||||
|
||||
# This is a simplified approach. In production, use proper state management
|
||||
text = message.text.strip()
|
||||
|
||||
# Try to determine the type based on context
|
||||
# For now, we'll assume it's a reward if it's a number
|
||||
try:
|
||||
amount = int(text)
|
||||
if 1 <= amount <= 1000:
|
||||
# Generate reward QR codes
|
||||
await generate_reward_qr_codes(message, amount)
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# If not a number, ask for clarification
|
||||
await message.answer(
|
||||
"🎫 Пожалуйста, уточните тип QR-кода:\n\n"
|
||||
"1. Для награды: введите число (1-1000)\n"
|
||||
"2. Для викторины: введите 'quiz ID' (например: quiz 123)\n"
|
||||
"3. Для магазина: введите 'shop действие' (например: shop discount_10)\n\n"
|
||||
"Или используйте /admin для выбора типа",
|
||||
reply_markup=create_admin_keyboard()
|
||||
)
|
||||
|
||||
|
||||
async def generate_reward_qr_codes(message: Message, amount: int, count: int = 5) -> None:
|
||||
"""Generate reward QR codes"""
|
||||
async with QRService() as qr_service:
|
||||
try:
|
||||
is_valid, error_msg = qr_service.validate_qr_request("reward", str(amount), count)
|
||||
if not is_valid:
|
||||
await message.answer(f"❌ {error_msg}")
|
||||
return
|
||||
|
||||
tokens = await qr_service.generate_qr_codes("reward", str(amount), count)
|
||||
|
||||
response_text = qr_service.format_qr_examples(
|
||||
tokens,
|
||||
"reward",
|
||||
f"QR-коды для награды {amount} ⭐"
|
||||
)
|
||||
|
||||
await message.answer(
|
||||
response_text,
|
||||
reply_markup=create_admin_keyboard()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating QR codes: {e}")
|
||||
await message.answer(
|
||||
f"❌ Ошибка при генерации QR-кодов: {e}",
|
||||
reply_markup=create_admin_keyboard()
|
||||
)
|
||||
|
||||
|
||||
async def handle_admin_cancel_callback(callback: CallbackQuery) -> None:
|
||||
"""Handle admin cancel callback"""
|
||||
await callback.message.edit_text(
|
||||
"❌ Операция отменена",
|
||||
reply_markup=create_admin_keyboard()
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_admin_stats_callback(callback: CallbackQuery) -> None:
|
||||
"""Handle admin stats callback"""
|
||||
if not config.has_admin_privileges(callback.from_user.id):
|
||||
await callback.answer("❌ Доступ запрещен", show_alert=True)
|
||||
return
|
||||
|
||||
# Placeholder for stats
|
||||
await callback.answer("📊 Статистика в разработке", show_alert=True)
|
||||
|
||||
|
||||
async def handle_admin_settings_callback(callback: CallbackQuery) -> None:
|
||||
"""Handle admin settings callback"""
|
||||
if not config.is_admin(callback.from_user.id):
|
||||
await callback.answer("❌ Только администраторы могут менять настройки", show_alert=True)
|
||||
return
|
||||
|
||||
# Placeholder for settings
|
||||
await callback.answer("🔧 Настройки в разработке", show_alert=True)
|
||||
98
bot/bot.py
Normal file
98
bot/bot.py
Normal file
@ -0,0 +1,98 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import suppress
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.exceptions import TelegramAPIError
|
||||
from aiogram.filters import Command, CommandStart
|
||||
from aiogram import F
|
||||
from aiogram.types import Message
|
||||
|
||||
from config import config, app_config
|
||||
from handlers import (
|
||||
handle_start_command,
|
||||
handle_text_message,
|
||||
handle_stats_callback,
|
||||
handle_help_command,
|
||||
handle_unknown_command,
|
||||
handle_qr_info_command,
|
||||
)
|
||||
from admin_handlers import (
|
||||
handle_admin_command,
|
||||
handle_generate_qr_callback,
|
||||
handle_qr_type_callback,
|
||||
handle_qr_generation_text,
|
||||
handle_admin_cancel_callback,
|
||||
handle_admin_stats_callback,
|
||||
handle_admin_settings_callback,
|
||||
)
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=getattr(logging, app_config.LOG_LEVEL))
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Initialize bot
|
||||
bot = Bot(token=config.token, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
|
||||
dp = Dispatcher()
|
||||
|
||||
|
||||
def register_handlers():
|
||||
"""Register all bot handlers"""
|
||||
|
||||
# Start with just one handler to test
|
||||
dp.message.register(handle_start_command, CommandStart())
|
||||
|
||||
# Callback query handlers
|
||||
dp.callback_query.register(handle_stats_callback, F.data == "stats")
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main bot function"""
|
||||
try:
|
||||
# Validate configuration
|
||||
logger.info("Validating configuration...")
|
||||
config.validate()
|
||||
logger.info("Configuration validated successfully")
|
||||
|
||||
# Register handlers
|
||||
logger.info("Registering handlers...")
|
||||
register_handlers()
|
||||
logger.info("Handlers registered successfully")
|
||||
|
||||
# Set bot commands
|
||||
from aiogram.types import BotCommand
|
||||
await bot.set_my_commands([
|
||||
BotCommand(command="start", description="Запустить бота"),
|
||||
BotCommand(command="help", description="Помощь"),
|
||||
BotCommand(command="qr", description="Информация о QR-кодах"),
|
||||
BotCommand(command="admin", description="Панель администратора"),
|
||||
])
|
||||
|
||||
# Log bot startup
|
||||
logger.info(f"Starting bot @{config.bot_username}")
|
||||
logger.info(f"Frontend URL: {config.frontend_url}")
|
||||
logger.info(f"Backend API URL: {config.backend_api_url}")
|
||||
|
||||
# Start bot
|
||||
await dp.start_polling(bot)
|
||||
|
||||
except TelegramAPIError as e:
|
||||
logger.error(f"Telegram API error: {e}")
|
||||
raise
|
||||
except ValueError as e:
|
||||
logger.error(f"Configuration error: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}")
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Bot stopped by user")
|
||||
except Exception as e:
|
||||
logger.error(f"Bot crashed: {e}")
|
||||
82
bot/config.py
Normal file
82
bot/config.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""
|
||||
Bot configuration
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class BotConfig:
|
||||
"""Bot configuration class"""
|
||||
|
||||
def __init__(self):
|
||||
self.token: str = os.getenv("BOT_TOKEN", "")
|
||||
self.backend_api_url: str = os.getenv("BACKEND_API_URL", "http://localhost:8080")
|
||||
self.frontend_url: str = os.getenv("FRONTEND_URL", "http://localhost:5173")
|
||||
self.webhook_secret: Optional[str] = os.getenv("WEBHOOK_SECRET")
|
||||
self.webhook_url: Optional[str] = os.getenv("WEBHOOK_URL")
|
||||
self.bot_username: str = os.getenv("BOT_USERNAME", "QuizBot")
|
||||
self.admin_user_ids: list[int] = self._parse_admin_ids()
|
||||
self.operator_user_ids: list[int] = self._parse_operator_ids()
|
||||
|
||||
def _parse_admin_ids(self) -> list[int]:
|
||||
"""Parse admin user IDs from environment variable"""
|
||||
admin_ids = os.getenv("ADMIN_USER_IDS", "")
|
||||
if not admin_ids:
|
||||
return []
|
||||
try:
|
||||
return [int(id_str.strip()) for id_str in admin_ids.split(",")]
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
def _parse_operator_ids(self) -> list[int]:
|
||||
"""Parse operator user IDs from environment variable"""
|
||||
operator_ids = os.getenv("OPERATOR_USER_IDS", "")
|
||||
if not operator_ids:
|
||||
return []
|
||||
try:
|
||||
return [int(id_str.strip()) for id_str in operator_ids.split(",")]
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
def validate(self) -> bool:
|
||||
"""Validate configuration"""
|
||||
if not self.token:
|
||||
raise ValueError("BOT_TOKEN is required")
|
||||
|
||||
if not self.backend_api_url:
|
||||
raise ValueError("BACKEND_API_URL is required")
|
||||
|
||||
if not self.frontend_url:
|
||||
raise ValueError("FRONTEND_URL is required")
|
||||
|
||||
return True
|
||||
|
||||
def is_admin(self, user_id: int) -> bool:
|
||||
"""Check if user is admin"""
|
||||
return user_id in self.admin_user_ids
|
||||
|
||||
def is_operator(self, user_id: int) -> bool:
|
||||
"""Check if user is operator"""
|
||||
return user_id in self.operator_user_ids or self.is_admin(user_id)
|
||||
|
||||
def has_admin_privileges(self, user_id: int) -> bool:
|
||||
"""Check if user has admin or operator privileges"""
|
||||
return self.is_admin(user_id) or self.is_operator(user_id)
|
||||
|
||||
|
||||
class AppConfig:
|
||||
"""Application configuration"""
|
||||
|
||||
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "")
|
||||
REDIS_URL = os.getenv("REDIS_URL", "")
|
||||
|
||||
|
||||
# Global configuration instance
|
||||
config = BotConfig()
|
||||
app_config = AppConfig()
|
||||
83
bot/examples.py
Normal file
83
bot/examples.py
Normal file
@ -0,0 +1,83 @@
|
||||
"""
|
||||
Examples of generating deep links and QR codes for the bot
|
||||
"""
|
||||
|
||||
from utils import generate_deep_link, generate_qr_content
|
||||
|
||||
|
||||
def generate_example_links(bot_username: str = "YourBot"):
|
||||
"""Generate example deep links for testing"""
|
||||
|
||||
examples = {
|
||||
"reward_links": [
|
||||
generate_deep_link(bot_username, "reward", "10"),
|
||||
generate_deep_link(bot_username, "reward", "25"),
|
||||
generate_deep_link(bot_username, "reward", "50"),
|
||||
generate_deep_link(bot_username, "reward", "100"),
|
||||
],
|
||||
"quiz_links": [
|
||||
generate_deep_link(bot_username, "quiz", "1"),
|
||||
generate_deep_link(bot_username, "quiz", "2"),
|
||||
generate_deep_link(bot_username, "quiz", "3"),
|
||||
],
|
||||
"shop_link": generate_deep_link(bot_username, "shop", ""),
|
||||
"reward_item_links": [
|
||||
generate_deep_link(bot_username, "reward_item", "1"),
|
||||
generate_deep_link(bot_username, "reward_item", "2"),
|
||||
],
|
||||
}
|
||||
|
||||
return examples
|
||||
|
||||
|
||||
def generate_qr_examples(bot_username: str = "YourBot"):
|
||||
"""Generate QR code content examples"""
|
||||
|
||||
links = generate_example_links(bot_username)
|
||||
|
||||
qr_examples = {}
|
||||
|
||||
for category, link_list in links.items():
|
||||
if isinstance(link_list, list):
|
||||
qr_examples[category] = [generate_qr_content(link) for link in link_list]
|
||||
else:
|
||||
qr_examples[category] = generate_qr_content(link)
|
||||
|
||||
return qr_examples
|
||||
|
||||
|
||||
def print_examples():
|
||||
"""Print example links and QR content"""
|
||||
bot_username = "YourBot" # Replace with your bot username
|
||||
|
||||
print("🌟 Примеры Deep Links для бота @{bot_username}")
|
||||
print("=" * 50)
|
||||
|
||||
examples = generate_example_links(bot_username)
|
||||
|
||||
print("\n🎁 Ссылки для начисления звёзд:")
|
||||
for i, link in enumerate(examples["reward_links"], 1):
|
||||
print(f"{i}. {link}")
|
||||
|
||||
print("\n🧠 Ссылки для викторин:")
|
||||
for i, link in enumerate(examples["quiz_links"], 1):
|
||||
print(f"{i}. {link}")
|
||||
|
||||
print(f"\n🛒 Ссылка на магазин:")
|
||||
print(examples["shop_link"])
|
||||
|
||||
print("\n🎁 Ссылки на призы:")
|
||||
for i, link in enumerate(examples["reward_item_links"], 1):
|
||||
print(f"{i}. {link}")
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("📱 QR-коды можно сгенерировать на основе этих ссылок")
|
||||
print("Пример содержимого QR-кода:")
|
||||
|
||||
qr_examples = generate_qr_examples(bot_username)
|
||||
print(f"Для награды 10 звёзд: {qr_examples['reward_links'][0]}")
|
||||
print(f"Для викторины 1: {qr_examples['quiz_links'][0]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print_examples()
|
||||
194
bot/handlers.py
Normal file
194
bot/handlers.py
Normal file
@ -0,0 +1,194 @@
|
||||
"""
|
||||
Bot handlers
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import F, types
|
||||
from aiogram.filters import CommandStart, Command
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message, WebAppInfo
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import config
|
||||
from utils import (
|
||||
parse_startapp_parameter,
|
||||
get_welcome_message,
|
||||
validate_reward_amount,
|
||||
validate_quiz_id,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_main_keyboard(startapp_param: Optional[str] = None) -> InlineKeyboardMarkup:
|
||||
"""Create main keyboard with mini app button"""
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
# Mini app URL with startapp parameter
|
||||
mini_app_url = config.frontend_url
|
||||
if startapp_param:
|
||||
mini_app_url = f"{config.frontend_url}?startapp={startapp_param}"
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="🎯 Открыть Викторины",
|
||||
web_app=WebAppInfo(url=mini_app_url)
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text="📊 Статистика",
|
||||
callback_data="stats"
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
async def handle_start_command(message: Message) -> None:
|
||||
"""Handle /start command"""
|
||||
startapp_param = None
|
||||
|
||||
# Extract startapp parameter from command arguments
|
||||
if message.text and len(message.text.split()) > 1:
|
||||
startapp_param = message.text.split(maxsplit=1)[1]
|
||||
|
||||
# Parse the startapp parameter
|
||||
try:
|
||||
parsed_param = parse_startapp_parameter(startapp_param)
|
||||
|
||||
# Validate parameters
|
||||
if parsed_param["type"] == "reward":
|
||||
amount = parsed_param.get("amount", 0)
|
||||
if not validate_reward_amount(amount):
|
||||
logger.warning(f"Invalid reward amount: {amount}")
|
||||
parsed_param = {"type": "main"}
|
||||
|
||||
elif parsed_param["type"] == "quiz":
|
||||
quiz_id = parsed_param.get("quiz_id", "")
|
||||
if not validate_quiz_id(quiz_id):
|
||||
logger.warning(f"Invalid quiz ID: {quiz_id}")
|
||||
parsed_param = {"type": "main"}
|
||||
|
||||
# Get welcome message
|
||||
welcome_text = get_welcome_message(parsed_param)
|
||||
|
||||
# Log the start event
|
||||
logger.info(
|
||||
f"User {message.from_user.id} started bot with param: {startapp_param}, "
|
||||
f"parsed as: {parsed_param}"
|
||||
)
|
||||
|
||||
# Send welcome message with keyboard
|
||||
keyboard = create_main_keyboard(startapp_param)
|
||||
await message.answer(welcome_text, reply_markup=keyboard)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling start command: {e}")
|
||||
# Fallback to main message
|
||||
fallback_text = get_welcome_message({"type": "main"})
|
||||
keyboard = create_main_keyboard()
|
||||
await message.answer(fallback_text, reply_markup=keyboard)
|
||||
|
||||
|
||||
async def handle_text_message(message: Message) -> None:
|
||||
"""Handle text messages"""
|
||||
text = message.text.lower()
|
||||
|
||||
if any(word in text for word in ["викторина", "опрос", "тест"]):
|
||||
await message.answer(
|
||||
"🎯 Хотите пройти викторину? Нажмите на кнопку ниже!",
|
||||
reply_markup=create_main_keyboard()
|
||||
)
|
||||
elif any(word in text for word in ["звезд", "балл", "монет"]):
|
||||
await message.answer(
|
||||
"⭐ Узнайте свой баланс и потратьте звёзды в мини-приложении!",
|
||||
reply_markup=create_main_keyboard()
|
||||
)
|
||||
elif any(word in text for word in ["магазин", "приз", "подарок"]):
|
||||
await message.answer(
|
||||
"🛒 Загляните в наш магазин призов!",
|
||||
reply_markup=create_main_keyboard()
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
"🌟 Я бот для управления викторинами! Используйте кнопки ниже для доступа к мини-приложению.",
|
||||
reply_markup=create_main_keyboard()
|
||||
)
|
||||
|
||||
|
||||
async def handle_stats_callback(callback: types.CallbackQuery) -> None:
|
||||
"""Handle stats button click"""
|
||||
await callback.answer("Статистика доступна в мини-приложении!", show_alert=True)
|
||||
|
||||
|
||||
async def handle_help_command(message: Message) -> None:
|
||||
"""Handle /help command"""
|
||||
help_text = """
|
||||
🌟 <b>Звёздные Викторины - Помощь</b>
|
||||
|
||||
🎯 <b>Как начать:</b>
|
||||
Нажмите на кнопку "Открыть Викторины" и начните проходить викторины!
|
||||
|
||||
🎁 <b>Как получить звёзды:</b>
|
||||
• Проходите викторины
|
||||
• Сканируйте QR-коды
|
||||
• Участвуйте в акциях
|
||||
|
||||
🛒 <b>Как потратить звёзды:</b>
|
||||
• Обменивайте на призы в магазине
|
||||
• Получайте скидки и бонусы
|
||||
|
||||
📱 <b>QR-коды:</b>
|
||||
• Сканируйте QR-коды через камеру телефона
|
||||
• Или используйте встроенный сканер в приложении
|
||||
|
||||
🔗 <b>Deep Links:</b>
|
||||
• `reward_X` - получить X звёзд
|
||||
• `quiz_X` - открыть викторину X
|
||||
• `shop` - перейти в магазин
|
||||
|
||||
❓ <b>Вопросы?</b>
|
||||
Используйте кнопки ниже для доступа к мини-приложению!
|
||||
"""
|
||||
|
||||
await message.answer(help_text, reply_markup=create_main_keyboard())
|
||||
|
||||
|
||||
async def handle_unknown_command(message: Message) -> None:
|
||||
"""Handle unknown commands"""
|
||||
await message.answer(
|
||||
"❌ Неизвестная команда. Используйте /help для получения помощи.",
|
||||
reply_markup=create_main_keyboard()
|
||||
)
|
||||
|
||||
|
||||
async def handle_qr_info_command(message: Message) -> None:
|
||||
"""Handle /qr command for QR information"""
|
||||
info_text = """
|
||||
🎫 <b>Информация о QR-кодах</b>
|
||||
|
||||
🔒 <b>Система безопасности:</b>
|
||||
• QR-коды содержат уникальные токены
|
||||
• Каждый токен одноразовый
|
||||
• Срок действия - 30 дней
|
||||
• Проверка на стороне сервера
|
||||
|
||||
🎯 <b>Типы QR-кодов:</b>
|
||||
• 💰 <b>reward</b> - начисление звёзд
|
||||
• 🧠 <b>quiz</b> - открытие викторины
|
||||
• 🛒 <b>shop</b> - действия в магазине
|
||||
|
||||
📱 <b>Как использовать:</b>
|
||||
1. Администратор генерирует QR-коды
|
||||
2. QR-коды размещаются в нужных местах
|
||||
3. Пользователи сканируют их через приложение
|
||||
4. Приложение отправляет токен на валидацию
|
||||
|
||||
⚡ <b>Для администраторов:</b>
|
||||
Используйте /admin для генерации QR-кодов
|
||||
"""
|
||||
|
||||
await message.answer(info_text, reply_markup=create_main_keyboard())
|
||||
177
bot/qr_service.py
Normal file
177
bot/qr_service.py
Normal file
@ -0,0 +1,177 @@
|
||||
"""
|
||||
QR service for backend API integration
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from config import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QRService:
|
||||
"""Service for QR code operations via backend API"""
|
||||
|
||||
def __init__(self):
|
||||
self.api_url = config.backend_api_url
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry"""
|
||||
self.session = aiohttp.ClientSession()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit"""
|
||||
if self.session:
|
||||
await self.session.close()
|
||||
|
||||
async def generate_qr_codes(self, qr_type: str, value: str, count: int) -> List[str]:
|
||||
"""
|
||||
Generate QR codes via backend API
|
||||
|
||||
Args:
|
||||
qr_type: Type of QR code ('reward', 'quiz', 'shop')
|
||||
value: Value for the QR code
|
||||
count: Number of QR codes to generate
|
||||
|
||||
Returns:
|
||||
List of generated tokens
|
||||
"""
|
||||
if not self.session:
|
||||
raise RuntimeError("QRService must be used as async context manager")
|
||||
|
||||
url = f"{self.api_url}/api/admin/qrcodes"
|
||||
|
||||
payload = {
|
||||
"type": qr_type,
|
||||
"value": value,
|
||||
"count": count
|
||||
}
|
||||
|
||||
try:
|
||||
async with self.session.post(url, json=payload) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
if data.get("success"):
|
||||
return data.get("data", {}).get("tokens", [])
|
||||
else:
|
||||
logger.error(f"Backend API error: {data.get('message')}")
|
||||
raise Exception(data.get("message", "Unknown error"))
|
||||
else:
|
||||
error_text = await response.text()
|
||||
logger.error(f"Backend API request failed: {response.status} - {error_text}")
|
||||
raise Exception(f"API request failed with status {response.status}")
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"Network error: {e}")
|
||||
raise Exception(f"Network error: {e}")
|
||||
|
||||
async def validate_qr_token(self, token: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate QR token via backend API
|
||||
|
||||
Args:
|
||||
token: QR token to validate
|
||||
|
||||
Returns:
|
||||
Validation result from backend
|
||||
"""
|
||||
if not self.session:
|
||||
raise RuntimeError("QRService must be used as async context manager")
|
||||
|
||||
url = f"{self.api_url}/api/qr/validate"
|
||||
|
||||
payload = {
|
||||
"payload": token
|
||||
}
|
||||
|
||||
try:
|
||||
async with self.session.post(url, json=payload) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
if data.get("success"):
|
||||
return data.get("data", {})
|
||||
else:
|
||||
logger.error(f"Validation failed: {data.get('message')}")
|
||||
raise Exception(data.get("message", "Validation failed"))
|
||||
else:
|
||||
error_text = await response.text()
|
||||
logger.error(f"Validation request failed: {response.status} - {error_text}")
|
||||
raise Exception(f"Validation failed with status {response.status}")
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"Network error during validation: {e}")
|
||||
raise Exception(f"Network error: {e}")
|
||||
|
||||
def format_qr_examples(self, tokens: List[str], qr_type: str, description: str) -> str:
|
||||
"""
|
||||
Format QR code examples for display
|
||||
|
||||
Args:
|
||||
tokens: List of QR tokens
|
||||
qr_type: Type of QR codes
|
||||
description: Description of the QR codes
|
||||
|
||||
Returns:
|
||||
Formatted message with QR examples
|
||||
"""
|
||||
message = f"🎫 {description}\n\n"
|
||||
message += "📱 QR-коды содержат следующие токены:\n\n"
|
||||
|
||||
for i, token in enumerate(tokens, 1):
|
||||
message += f"{i}. `{token}`\n"
|
||||
|
||||
message += f"\n⚡ Всего сгенерировано: {len(tokens)} шт."
|
||||
message += f"\n📅 Срок действия: 30 дней"
|
||||
message += f"\n🔒 Одноразовое использование"
|
||||
|
||||
return message
|
||||
|
||||
def validate_qr_request(self, qr_type: str, value: str, count: int) -> tuple[bool, str]:
|
||||
"""
|
||||
Validate QR generation request parameters
|
||||
|
||||
Args:
|
||||
qr_type: Type of QR code
|
||||
value: Value for the QR code
|
||||
count: Number of QR codes to generate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not qr_type:
|
||||
return False, "Тип QR-кода не может быть пустым"
|
||||
|
||||
if not value:
|
||||
return False, "Значение QR-кода не может быть пустым"
|
||||
|
||||
if count <= 0 or count > 100:
|
||||
return False, "Количество должно быть от 1 до 100"
|
||||
|
||||
valid_types = ["reward", "quiz", "shop"]
|
||||
if qr_type not in valid_types:
|
||||
return False, f"Недопустимый тип. Допустимые: {', '.join(valid_types)}"
|
||||
|
||||
# Additional validation based on type
|
||||
if qr_type == "reward":
|
||||
try:
|
||||
amount = int(value)
|
||||
if amount <= 0 or amount > 1000:
|
||||
return False, "Сумма награды должна быть от 1 до 1000"
|
||||
except ValueError:
|
||||
return False, "Сумма награды должна быть числом"
|
||||
|
||||
elif qr_type == "quiz":
|
||||
try:
|
||||
quiz_id = int(value)
|
||||
if quiz_id <= 0:
|
||||
return False, "ID викторины должен быть положительным числом"
|
||||
except ValueError:
|
||||
return False, "ID викторины должен быть числом"
|
||||
|
||||
return True, ""
|
||||
3
bot/requirements.txt
Normal file
3
bot/requirements.txt
Normal file
@ -0,0 +1,3 @@
|
||||
aiogram==3.15.0
|
||||
python-dotenv==1.0.1
|
||||
aiohttp==3.10.10
|
||||
148
bot/utils.py
Normal file
148
bot/utils.py
Normal file
@ -0,0 +1,148 @@
|
||||
"""
|
||||
Utility functions for the Telegram bot
|
||||
"""
|
||||
|
||||
import urllib.parse
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
def generate_deep_link(bot_username: str, param_type: str, value: str = "") -> str:
|
||||
"""
|
||||
Generate deep link for the bot
|
||||
|
||||
Args:
|
||||
bot_username: Telegram bot username (without @)
|
||||
param_type: Type of parameter (reward, quiz, shop, reward_item)
|
||||
value: Value for the parameter (e.g., reward amount, quiz ID)
|
||||
|
||||
Returns:
|
||||
Deep link URL
|
||||
"""
|
||||
if param_type == "shop":
|
||||
startapp = "shop"
|
||||
elif param_type == "reward":
|
||||
startapp = f"reward_{value}"
|
||||
elif param_type == "quiz":
|
||||
startapp = f"quiz_{value}"
|
||||
elif param_type == "reward_item":
|
||||
startapp = f"reward_{value}"
|
||||
else:
|
||||
startapp = ""
|
||||
|
||||
if startapp:
|
||||
return f"https://t.me/{bot_username}?startapp={startapp}"
|
||||
else:
|
||||
return f"https://t.me/{bot_username}"
|
||||
|
||||
|
||||
def generate_qr_content(deep_link: str) -> str:
|
||||
"""
|
||||
Generate content for QR code
|
||||
|
||||
Args:
|
||||
deep_link: The deep link to encode in QR
|
||||
|
||||
Returns:
|
||||
QR content (URL encoded)
|
||||
"""
|
||||
return urllib.parse.quote(deep_link)
|
||||
|
||||
|
||||
def parse_startapp_parameter(startapp: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse startapp parameter from deep link
|
||||
|
||||
Args:
|
||||
startapp: The startapp parameter from Telegram
|
||||
|
||||
Returns:
|
||||
Dictionary with parsed data
|
||||
"""
|
||||
if not startapp:
|
||||
return {"type": "main"}
|
||||
|
||||
if startapp.startswith("reward_"):
|
||||
try:
|
||||
amount = int(startapp.split("_")[1])
|
||||
return {"type": "reward", "amount": amount}
|
||||
except (IndexError, ValueError):
|
||||
return {"type": "main"}
|
||||
|
||||
elif startapp.startswith("quiz_"):
|
||||
quiz_id = startapp.split("_")[1]
|
||||
return {"type": "quiz", "quiz_id": quiz_id}
|
||||
|
||||
elif startapp == "shop":
|
||||
return {"type": "shop"}
|
||||
|
||||
elif startapp.startswith("reward_"):
|
||||
reward_id = startapp.split("_")[1]
|
||||
return {"type": "reward_item", "reward_id": reward_id}
|
||||
|
||||
else:
|
||||
return {"type": "main"}
|
||||
|
||||
|
||||
def get_welcome_message(param_data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Get welcome message based on parsed parameter
|
||||
|
||||
Args:
|
||||
param_data: Parsed parameter data
|
||||
|
||||
Returns:
|
||||
Welcome message text
|
||||
"""
|
||||
param_type = param_data.get("type", "main")
|
||||
|
||||
messages = {
|
||||
"main": (
|
||||
"🌟 Добро пожаловать в <b>Звёздные Викторины</b>!\n\n"
|
||||
"Проходите викторины, сканируйте QR-коды и получайте звёзды, "
|
||||
"которые можно обменять на призы!"
|
||||
),
|
||||
"reward": (
|
||||
f"🎁 Поздравляем! Вы получили {param_data.get('amount', 0)} ⭐\n\n"
|
||||
"Откройте мини-приложение, чтобы использовать свои звёзды!"
|
||||
),
|
||||
"quiz": (
|
||||
"🧠 Готовы проверить свои знания?\n\n"
|
||||
"Откройте викторину и начните проходить!"
|
||||
),
|
||||
"shop": (
|
||||
"🛒 Добро пожаловать в магазин призов!\n\n"
|
||||
"Обменивайте звёзды на замечательные призы!"
|
||||
),
|
||||
"reward_item": (
|
||||
"🎁 Специальный приз ждет вас!\n\n"
|
||||
"Откройте магазин, чтобы узнать подробности!"
|
||||
)
|
||||
}
|
||||
|
||||
return messages.get(param_type, messages["main"])
|
||||
|
||||
|
||||
def validate_reward_amount(amount: int) -> bool:
|
||||
"""
|
||||
Validate reward amount
|
||||
|
||||
Args:
|
||||
amount: Reward amount to validate
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
return isinstance(amount, int) and amount > 0 and amount <= 1000
|
||||
|
||||
|
||||
def validate_quiz_id(quiz_id: str) -> bool:
|
||||
"""
|
||||
Validate quiz ID
|
||||
|
||||
Args:
|
||||
quiz_id: Quiz ID to validate
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
return isinstance(quiz_id, str) and len(quiz_id) > 0 and quiz_id.isalnum()
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
69
frontend/README.md
Normal file
69
frontend/README.md
Normal file
@ -0,0 +1,69 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
<!-- Telegram Web App Script -->
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
39
frontend/package.json
Normal file
39
frontend/package.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.2",
|
||||
"@mui/material": "^7.3.2",
|
||||
"@mui/x-data-grid": "^8.11.2",
|
||||
"@types/node": "^24.5.1",
|
||||
"axios": "^1.12.2",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"lucide-react": "^0.544.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vite": "^7.1.2"
|
||||
}
|
||||
}
|
||||
2989
frontend/pnpm-lock.yaml
Normal file
2989
frontend/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
42
frontend/src/App.css
Normal file
42
frontend/src/App.css
Normal file
@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
101
frontend/src/App.tsx
Normal file
101
frontend/src/App.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import { Layout } from './components/layout/Layout';
|
||||
import { DeepLinkHandler } from './components/DeepLinkHandler';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { QuizPage } from './pages/QuizPage';
|
||||
import { ShopPage } from './pages/ShopPage';
|
||||
import { ProfilePage } from './pages/ProfilePage';
|
||||
import { QRScannerPage } from './pages/QRScannerPage';
|
||||
import { QuizResultPage } from './pages/QuizResultPage';
|
||||
import { AdminPage } from './pages/AdminPage';
|
||||
import theme from './theme';
|
||||
|
||||
// Component to handle Telegram auto-authentication
|
||||
const TelegramAuthHandler: React.FC = () => {
|
||||
const { login, isAuthenticated, loading } = useAuth();
|
||||
const [authAttempted, setAuthAttempted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleTelegramAuth = async () => {
|
||||
if (window.Telegram?.WebApp && !isAuthenticated && !authAttempted && !loading) {
|
||||
const initData = window.Telegram.WebApp.initData;
|
||||
if (initData) {
|
||||
console.log('Attempting auto-auth with Telegram initData');
|
||||
setAuthAttempted(true);
|
||||
const success = await login(initData);
|
||||
if (success) {
|
||||
console.log('Auto-auth successful');
|
||||
} else {
|
||||
console.log('Auto-auth failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleTelegramAuth();
|
||||
}, [isAuthenticated, authAttempted, loading, login]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
if (window.Telegram?.WebApp) {
|
||||
const webApp = window.Telegram.WebApp;
|
||||
|
||||
// Initialize Telegram Web App
|
||||
webApp.ready();
|
||||
webApp.expand();
|
||||
|
||||
// Optional features
|
||||
webApp.enableClosingConfirmation?.();
|
||||
|
||||
// Set theme colors if available
|
||||
webApp.setBackgroundColor?.('#0F0F0F');
|
||||
webApp.setHeaderColor?.('#1A1A1A');
|
||||
|
||||
// Handle viewport changes if available
|
||||
const handleViewportChanged = () => {
|
||||
webApp.expand();
|
||||
};
|
||||
|
||||
webApp.onEvent?.('viewportChanged', handleViewportChanged);
|
||||
|
||||
return () => {
|
||||
webApp.offEvent?.('viewportChanged', handleViewportChanged);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/home" replace />} />
|
||||
<Route path="/home" element={<><TelegramAuthHandler /><HomePage /></>} />
|
||||
<Route path="/quiz/:id" element={<QuizPage />} />
|
||||
<Route path="/quiz-result" element={<QuizResultPage />} />
|
||||
<Route path="/shop" element={<ShopPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/qr-scanner" element={<QRScannerPage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="*" element={<Navigate to="/home" replace />} />
|
||||
</Routes>
|
||||
<DeepLinkHandler />
|
||||
</Layout>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
345
frontend/src/components/DeepLinkHandler.tsx
Normal file
345
frontend/src/components/DeepLinkHandler.tsx
Normal file
@ -0,0 +1,345 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Modal,
|
||||
Paper,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Quiz as QuizIcon,
|
||||
ShoppingBag,
|
||||
CheckCircle,
|
||||
Error
|
||||
} from '@mui/icons-material';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { apiService } from '../services/api';
|
||||
|
||||
interface DeepLinkHandlerProps {
|
||||
onActionComplete?: () => void;
|
||||
}
|
||||
|
||||
export const DeepLinkHandler: React.FC<DeepLinkHandlerProps> = ({ onActionComplete }) => {
|
||||
const { deepLinkAction, clearDeepLinkAction, updateUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (deepLinkAction) {
|
||||
handleDeepLinkAction();
|
||||
}
|
||||
}, [deepLinkAction]);
|
||||
|
||||
const handleDeepLinkAction = async () => {
|
||||
if (!deepLinkAction) return;
|
||||
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
switch (deepLinkAction.type) {
|
||||
case 'reward':
|
||||
await handleRewardAction(deepLinkAction.value);
|
||||
break;
|
||||
case 'quiz':
|
||||
await handleQuizAction(deepLinkAction.value);
|
||||
break;
|
||||
case 'shop':
|
||||
await handleShopAction();
|
||||
break;
|
||||
default:
|
||||
setError('Неизвестный тип действия');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Deep link action error:', err);
|
||||
setError('Произошла ошибка при обработке действия');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRewardAction = async (value: string) => {
|
||||
const stars = parseInt(value);
|
||||
if (isNaN(stars) || stars <= 0) {
|
||||
setError('Некорректное количество звезд');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiService.validateQR(`reward:${stars}`);
|
||||
if (response.success && response.data) {
|
||||
setResult({
|
||||
type: 'reward',
|
||||
value: stars,
|
||||
message: response.data.message || `Вы получили ${stars} ⭐!`
|
||||
});
|
||||
|
||||
// Update user balance
|
||||
const userResponse = await apiService.getCurrentUser();
|
||||
if (userResponse.success && userResponse.data) {
|
||||
updateUser(userResponse.data);
|
||||
}
|
||||
} else {
|
||||
setError(response.message || 'Не удалось начислить звезды');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Ошибка при обработке награды');
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuizAction = async (quizId: string) => {
|
||||
const id = parseInt(quizId);
|
||||
if (isNaN(id)) {
|
||||
setError('Некорректный ID викторины');
|
||||
return;
|
||||
}
|
||||
|
||||
setResult({
|
||||
type: 'quiz',
|
||||
value: id,
|
||||
message: 'Викторина найдена! Готовы начать?'
|
||||
});
|
||||
};
|
||||
|
||||
const handleShopAction = async () => {
|
||||
setResult({
|
||||
type: 'shop',
|
||||
value: '',
|
||||
message: 'Переход в магазин призов'
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
clearDeepLinkAction();
|
||||
setResult(null);
|
||||
setError(null);
|
||||
onActionComplete?.();
|
||||
};
|
||||
|
||||
const handleGoToQuiz = () => {
|
||||
clearDeepLinkAction();
|
||||
setResult(null);
|
||||
navigate(`/quiz/${result.value}`);
|
||||
};
|
||||
|
||||
const handleGoToShop = () => {
|
||||
clearDeepLinkAction();
|
||||
setResult(null);
|
||||
navigate('/shop');
|
||||
};
|
||||
|
||||
if (!deepLinkAction) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Processing Modal */}
|
||||
<Modal
|
||||
open={processing}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
sx={{
|
||||
backgroundColor: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: 2,
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<CircularProgress sx={{ color: '#FFD700', mb: 2 }} />
|
||||
<Typography sx={{ color: '#ffffff' }}>
|
||||
Обработка действия...
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Modal>
|
||||
|
||||
{/* Result Modal */}
|
||||
<Modal
|
||||
open={!!result || !!error}
|
||||
onClose={handleClose}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
sx={{
|
||||
backgroundColor: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: 2,
|
||||
p: 4,
|
||||
maxWidth: 400,
|
||||
width: '90%',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{error ? (
|
||||
<>
|
||||
<Error sx={{ fontSize: 64, color: '#f44336', mb: 2 }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#f44336',
|
||||
fontWeight: 'bold',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
Ошибка
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: '#ffffff', mb: 3 }}
|
||||
>
|
||||
{error}
|
||||
</Typography>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={handleClose}
|
||||
sx={{
|
||||
backgroundColor: '#f44336',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
>
|
||||
Закрыть
|
||||
</Button>
|
||||
</>
|
||||
) : result?.type === 'reward' && (
|
||||
<>
|
||||
<CheckCircle sx={{ fontSize: 64, color: '#4CAF50', mb: 2 }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#4CAF50',
|
||||
fontWeight: 'bold',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
Награда получена!
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: '#ffffff', mb: 1 }}
|
||||
>
|
||||
Вы получили {result.value} ⭐
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: '#888', mb: 3 }}
|
||||
>
|
||||
{result.message}
|
||||
</Typography>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={handleClose}
|
||||
sx={{
|
||||
backgroundColor: '#4CAF50',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
>
|
||||
Отлично!
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{result?.type === 'quiz' && (
|
||||
<>
|
||||
<QuizIcon sx={{ fontSize: 64, color: '#FFD700', mb: 2 }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#FFD700',
|
||||
fontWeight: 'bold',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
Викторина найдена!
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: '#ffffff', mb: 1 }}
|
||||
>
|
||||
{result.message}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: '#888', mb: 3 }}
|
||||
>
|
||||
Готовы пройти викторину?
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onClick={handleClose}
|
||||
sx={{
|
||||
borderColor: '#333',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
>
|
||||
Позже
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={handleGoToQuiz}
|
||||
sx={{
|
||||
backgroundColor: '#FFD700',
|
||||
color: '#000000',
|
||||
}}
|
||||
>
|
||||
Начать
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{result?.type === 'shop' && (
|
||||
<>
|
||||
<ShoppingBag sx={{ fontSize: 64, color: '#FF9800', mb: 2 }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#FF9800',
|
||||
fontWeight: 'bold',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
Магазин призов
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: '#ffffff', mb: 3 }}
|
||||
>
|
||||
{result.message}
|
||||
</Typography>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={handleGoToShop}
|
||||
sx={{
|
||||
backgroundColor: '#FF9800',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
>
|
||||
В магазин
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
19
frontend/src/components/GridItem.tsx
Normal file
19
frontend/src/components/GridItem.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Grid, type GridProps } from '@mui/material';
|
||||
|
||||
interface GridItemProps extends Omit<GridProps, 'item'> {
|
||||
component?: React.ElementType;
|
||||
xs?: number;
|
||||
sm?: number;
|
||||
md?: number;
|
||||
lg?: number;
|
||||
xl?: number;
|
||||
}
|
||||
|
||||
export const GridItem: React.FC<GridItemProps> = ({ children, component = 'div', ...props }) => {
|
||||
return (
|
||||
<Grid {...props}>
|
||||
{React.createElement(component, {}, children)}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
49
frontend/src/components/layout/Layout.tsx
Normal file
49
frontend/src/components/layout/Layout.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Box, Container } from '@mui/material';
|
||||
import { Navigation } from './Navigation';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
backgroundColor: '#0F0F0F',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
backgroundColor: '#0F0F0F',
|
||||
paddingBottom: '56px', // Height of bottom navigation
|
||||
}}
|
||||
>
|
||||
<Container
|
||||
maxWidth="md"
|
||||
sx={{
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Container>
|
||||
<Navigation />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
153
frontend/src/components/layout/Navigation.tsx
Normal file
153
frontend/src/components/layout/Navigation.tsx
Normal file
@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
BottomNavigation,
|
||||
BottomNavigationAction,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Quiz as QuizIcon,
|
||||
ShoppingBag as ShopIcon,
|
||||
Person as PersonIcon,
|
||||
QrCodeScanner as QrIcon,
|
||||
AdminPanelSettings as AdminIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
export const Navigation: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, isAdmin } = useAuth();
|
||||
|
||||
const getValue = () => {
|
||||
if (location.pathname === '/' || location.pathname === '/home') return 0;
|
||||
if (location.pathname.startsWith('/shop')) return 1;
|
||||
if (location.pathname.startsWith('/profile')) return 2;
|
||||
if (location.pathname.startsWith('/qr-scanner')) return 3;
|
||||
if (location.pathname.startsWith('/admin')) return 4;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const handleChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||
switch (newValue) {
|
||||
case 0:
|
||||
navigate('/home');
|
||||
break;
|
||||
case 1:
|
||||
navigate('/shop');
|
||||
break;
|
||||
case 2:
|
||||
navigate('/profile');
|
||||
break;
|
||||
case 3:
|
||||
navigate('/qr-scanner');
|
||||
break;
|
||||
case 4:
|
||||
navigate('/admin');
|
||||
break;
|
||||
default:
|
||||
navigate('/home');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
backgroundColor: '#1A1A1A',
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
boxShadow: '0 -4px 20px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
elevation={0}
|
||||
>
|
||||
<BottomNavigation
|
||||
value={getValue()}
|
||||
onChange={handleChange}
|
||||
sx={{
|
||||
backgroundColor: 'transparent',
|
||||
'& .Mui-selected': {
|
||||
color: '#FFD700',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<BottomNavigationAction
|
||||
label="Викторины"
|
||||
icon={<QuizIcon />}
|
||||
sx={{
|
||||
color: '#B0B0B0',
|
||||
'&.Mui-selected': {
|
||||
color: '#FFD700',
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 215, 0, 0.1)',
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<BottomNavigationAction
|
||||
label="Магазин"
|
||||
icon={<ShopIcon />}
|
||||
sx={{
|
||||
color: '#B0B0B0',
|
||||
'&.Mui-selected': {
|
||||
color: '#FFD700',
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 215, 0, 0.1)',
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<BottomNavigationAction
|
||||
label="Профиль"
|
||||
icon={<PersonIcon />}
|
||||
sx={{
|
||||
color: '#B0B0B0',
|
||||
'&.Mui-selected': {
|
||||
color: '#FFD700',
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 215, 0, 0.1)',
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<BottomNavigationAction
|
||||
label="Сканер"
|
||||
icon={<QrIcon />}
|
||||
sx={{
|
||||
color: '#B0B0B0',
|
||||
'&.Mui-selected': {
|
||||
color: '#FFD700',
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 215, 0, 0.1)',
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<BottomNavigationAction
|
||||
label="Админ"
|
||||
icon={<AdminIcon />}
|
||||
sx={{
|
||||
color: '#B0B0B0',
|
||||
'&.Mui-selected': {
|
||||
color: '#FFD700',
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 215, 0, 0.1)',
|
||||
transition: 'all 0.2s ease',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</BottomNavigation>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
194
frontend/src/components/ui/AnswerOption.tsx
Normal file
194
frontend/src/components/ui/AnswerOption.tsx
Normal file
@ -0,0 +1,194 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Card } from '@mui/material';
|
||||
import { RadioButtonUnchecked, CheckBoxOutlineBlank, CheckCircle, CheckBox } from '@mui/icons-material';
|
||||
|
||||
interface AnswerOptionProps {
|
||||
id: string;
|
||||
text: string;
|
||||
type: 'single' | 'multiple';
|
||||
isSelected: boolean;
|
||||
isCorrect?: boolean;
|
||||
showResult?: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const AnswerOption: React.FC<AnswerOptionProps> = ({
|
||||
id,
|
||||
text,
|
||||
type,
|
||||
isSelected,
|
||||
isCorrect,
|
||||
showResult = false,
|
||||
onSelect,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const handleClick = () => {
|
||||
console.log('AnswerOption handleClick called:', { id, disabled, showResult });
|
||||
if (!disabled && !showResult) {
|
||||
console.log('Calling onSelect with:', id);
|
||||
onSelect(id);
|
||||
}
|
||||
};
|
||||
|
||||
const getCardStyles = () => {
|
||||
if (showResult) {
|
||||
if (isCorrect) {
|
||||
return {
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.1)',
|
||||
border: '2px solid #4CAF50',
|
||||
cursor: 'default',
|
||||
};
|
||||
} else if (isSelected && !isCorrect) {
|
||||
return {
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
border: '2px solid #F44336',
|
||||
cursor: 'default',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
return {
|
||||
backgroundColor: 'rgba(255, 215, 0, 0.1)',
|
||||
border: '2px solid #FFD700',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.02)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
};
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
if (showResult) {
|
||||
if (isCorrect) {
|
||||
return type === 'single' ? (
|
||||
<CheckCircle sx={{ color: '#4CAF50', fontSize: 24 }} />
|
||||
) : (
|
||||
<CheckBox sx={{ color: '#4CAF50', fontSize: 24 }} />
|
||||
);
|
||||
} else if (isSelected && !isCorrect) {
|
||||
return type === 'single' ? (
|
||||
<CheckCircle sx={{ color: '#F44336', fontSize: 24 }} />
|
||||
) : (
|
||||
<CheckBox sx={{ color: '#F44336', fontSize: 24 }} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
return type === 'single' ? (
|
||||
<CheckCircle sx={{ color: '#FFD700', fontSize: 24 }} />
|
||||
) : (
|
||||
<CheckBox sx={{ color: '#FFD700', fontSize: 24 }} />
|
||||
);
|
||||
}
|
||||
|
||||
return type === 'single' ? (
|
||||
<RadioButtonUnchecked sx={{ color: '#B0B0B0', fontSize: 24 }} />
|
||||
) : (
|
||||
<CheckBoxOutlineBlank sx={{ color: '#B0B0B0', fontSize: 24 }} />
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
mb: 2,
|
||||
borderRadius: 1.5,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: !disabled && !showResult ? 'pointer' : 'default',
|
||||
zIndex: 1,
|
||||
...getCardStyles(),
|
||||
'&:hover': !disabled && !showResult ? {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(0, 0, 0, 0.3)',
|
||||
border: isSelected ? '2px solid #FFD700' : '1px solid rgba(255, 215, 0, 0.3)',
|
||||
} : {},
|
||||
'&::before': !disabled && !showResult ? {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '-100%',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent)',
|
||||
transition: 'left 0.5s',
|
||||
zIndex: -1,
|
||||
} : {},
|
||||
'&:hover::before': !disabled && !showResult ? {
|
||||
left: '100%',
|
||||
} : {},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 3,
|
||||
p: 3,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Icon */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 24,
|
||||
mt: 0.5,
|
||||
}}
|
||||
>
|
||||
{getIcon()}
|
||||
</Box>
|
||||
|
||||
{/* Text */}
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: showResult
|
||||
? isCorrect
|
||||
? '#4CAF50'
|
||||
: isSelected && !isCorrect
|
||||
? '#F44336'
|
||||
: '#FFFFFF'
|
||||
: '#FFFFFF',
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.5,
|
||||
fontSize: 16,
|
||||
transition: 'color 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Selection indicator */}
|
||||
{isSelected && !showResult && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(135deg, rgba(255, 215, 0, 0.05) 0%, rgba(255, 215, 0, 0.02) 100%)',
|
||||
pointerEvents: 'none',
|
||||
animation: 'fadeIn 0.3s ease-out',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
281
frontend/src/components/ui/CardQuiz.tsx
Normal file
281
frontend/src/components/ui/CardQuiz.tsx
Normal file
@ -0,0 +1,281 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardMedia, Box, Typography, Chip } from '@mui/material';
|
||||
import { AccessTime as AccessTimeIcon, Star as StarIcon } from '@mui/icons-material';
|
||||
import type { Quiz } from '../../types';
|
||||
|
||||
interface CardQuizProps {
|
||||
quiz: Quiz;
|
||||
onStart: (quizId: number) => void;
|
||||
isCompleted?: boolean;
|
||||
cooldownTime?: string;
|
||||
canStart?: boolean;
|
||||
}
|
||||
|
||||
export const CardQuiz: React.FC<CardQuizProps> = ({
|
||||
quiz,
|
||||
onStart,
|
||||
isCompleted = false,
|
||||
cooldownTime,
|
||||
canStart = true,
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
border: '1px solid rgba(255, 215, 0, 0.3)',
|
||||
boxShadow: '0 12px 40px rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: isCompleted
|
||||
? 'linear-gradient(135deg, rgba(76, 175, 80, 0.1) 0%, rgba(76, 175, 80, 0.05) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(255, 215, 0, 0.1) 0%, rgba(255, 215, 0, 0.05) 100%)',
|
||||
zIndex: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Image with gradient overlay */}
|
||||
<Box sx={{ position: 'relative', overflow: 'hidden' }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="180"
|
||||
image={quiz.image_url || '/placeholder-quiz.jpg'}
|
||||
alt={quiz.title}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '180px',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center',
|
||||
transition: 'transform 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(to bottom, rgba(0,0,0,0.1) 0%, rgba(0,0,0,0.6) 100%)',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Status badge */}
|
||||
{isCompleted && (
|
||||
<Chip
|
||||
label="Пройдено ✓"
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.9)',
|
||||
color: '#FFFFFF',
|
||||
fontWeight: 'bold',
|
||||
border: 'none',
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<CardContent
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
p: 3,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#FFFFFF',
|
||||
fontWeight: 600,
|
||||
mb: 1,
|
||||
lineHeight: 1.3,
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
{quiz.title}
|
||||
</Typography>
|
||||
|
||||
{/* Description */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#B0B0B0',
|
||||
mb: 3,
|
||||
flexGrow: 1,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{quiz.description}
|
||||
</Typography>
|
||||
|
||||
{/* Info chips */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
flexWrap: 'wrap',
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
icon={
|
||||
<StarIcon
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: '#FFD700',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={`+${quiz.reward_stars} ⭐`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255, 215, 0, 0.15)',
|
||||
color: '#FFD700',
|
||||
border: '1px solid rgba(255, 215, 0, 0.3)',
|
||||
fontWeight: 600,
|
||||
'& .MuiChip-icon': {
|
||||
color: '#FFD700',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{quiz.has_timer && (
|
||||
<Chip
|
||||
icon={
|
||||
<AccessTimeIcon
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: '#FFFFFF',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={`${quiz.timer_per_question} сек`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
color: '#FFFFFF',
|
||||
border: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
fontWeight: 600,
|
||||
'& .MuiChip-icon': {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Action button */}
|
||||
<Box
|
||||
sx={{
|
||||
mt: 'auto',
|
||||
}}
|
||||
>
|
||||
{cooldownTime && !canStart ? (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
textAlign: 'center',
|
||||
backgroundColor: 'rgba(255, 152, 0, 0.1)',
|
||||
border: '1px solid rgba(255, 152, 0, 0.3)',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#FF9800',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{cooldownTime}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: canStart ? 'pointer' : 'default',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '-100%',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent)',
|
||||
transition: 'left 0.5s',
|
||||
},
|
||||
'&:hover::before': {
|
||||
left: '100%',
|
||||
},
|
||||
}}
|
||||
onClick={() => canStart && onStart(quiz.id)}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2.5,
|
||||
textAlign: 'center',
|
||||
background: canStart
|
||||
? 'linear-gradient(135deg, #FFD700 0%, #FFC700 100%)'
|
||||
: 'linear-gradient(135deg, #666666 0%, #555555 100%)',
|
||||
borderRadius: 1,
|
||||
boxShadow: canStart
|
||||
? '0 4px 15px rgba(255, 215, 0, 0.3)'
|
||||
: 'none',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: canStart ? 'translateY(-2px)' : 'none',
|
||||
boxShadow: canStart
|
||||
? '0 6px 20px rgba(255, 215, 0, 0.4)'
|
||||
: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: canStart ? '#000000' : '#FFFFFF',
|
||||
fontWeight: 700,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{isCompleted ? 'Повторить' : canStart ? 'Начать' : 'Недоступно'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
331
frontend/src/components/ui/CardReward.tsx
Normal file
331
frontend/src/components/ui/CardReward.tsx
Normal file
@ -0,0 +1,331 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardMedia, Box, Typography, Chip } from '@mui/material';
|
||||
import { Star as StarIcon, ShoppingBag as ShoppingBagIcon } from '@mui/icons-material';
|
||||
|
||||
interface CardRewardProps {
|
||||
reward: {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
image_url?: string;
|
||||
price_stars: number;
|
||||
delivery_type?: 'physical' | 'digital';
|
||||
instructions?: string;
|
||||
stock: number;
|
||||
is_active: boolean;
|
||||
};
|
||||
onBuy: (rewardId: number) => void;
|
||||
userStars: number;
|
||||
}
|
||||
|
||||
export const CardReward: React.FC<CardRewardProps> = ({
|
||||
reward,
|
||||
onBuy,
|
||||
userStars,
|
||||
}) => {
|
||||
const canAfford = userStars >= reward.price_stars;
|
||||
const inStock = reward.stock === -1 || reward.stock > 0; // -1 = infinite stock
|
||||
const isActive = reward.is_active;
|
||||
|
||||
const handleBuy = () => {
|
||||
if (canAfford && inStock && isActive) {
|
||||
onBuy(reward.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
border: '1px solid rgba(255, 215, 0, 0.3)',
|
||||
boxShadow: '0 12px 40px rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: isActive
|
||||
? 'linear-gradient(135deg, rgba(255, 215, 0, 0.05) 0%, rgba(255, 215, 0, 0.02) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.02) 0%, rgba(255, 255, 255, 0.01) 100%)',
|
||||
zIndex: 0,
|
||||
},
|
||||
opacity: isActive ? 1 : 0.6,
|
||||
}}
|
||||
>
|
||||
{/* Image with overlay */}
|
||||
<Box sx={{ position: 'relative', overflow: 'hidden' }}>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="200"
|
||||
image={reward.image_url || '/placeholder-reward.jpg'}
|
||||
alt={reward.title}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '200px',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center',
|
||||
transition: 'transform 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Stock badge */}
|
||||
{!inStock && (
|
||||
<Chip
|
||||
label="Нет в наличии"
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.9)',
|
||||
color: '#FFFFFF',
|
||||
fontWeight: 'bold',
|
||||
border: 'none',
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isActive && (
|
||||
<Chip
|
||||
label="Неактивно"
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
backgroundColor: 'rgba(158, 158, 158, 0.9)',
|
||||
color: '#FFFFFF',
|
||||
fontWeight: 'bold',
|
||||
border: 'none',
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{inStock && isActive && reward.stock > 0 && (
|
||||
<Chip
|
||||
label={`Осталось: ${reward.stock}`}
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.9)',
|
||||
color: '#FFFFFF',
|
||||
fontWeight: 'bold',
|
||||
border: 'none',
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{inStock && isActive && reward.stock === -1 && (
|
||||
<Chip
|
||||
label="∞"
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.9)',
|
||||
color: '#FFFFFF',
|
||||
fontWeight: 'bold',
|
||||
border: 'none',
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delivery type badge */}
|
||||
{reward.delivery_type && (
|
||||
<Chip
|
||||
label={reward.delivery_type === 'digital' ? 'Цифровой' : 'Физический'}
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
left: 12,
|
||||
backgroundColor: reward.delivery_type === 'digital' ? 'rgba(33, 150, 243, 0.9)' : 'rgba(156, 39, 176, 0.9)',
|
||||
color: '#FFFFFF',
|
||||
fontWeight: 'bold',
|
||||
border: 'none',
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<CardContent
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
p: 3,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Title */}
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#FFFFFF',
|
||||
fontWeight: 600,
|
||||
mb: 1,
|
||||
lineHeight: 1.3,
|
||||
fontSize: 18,
|
||||
}}
|
||||
>
|
||||
{reward.title}
|
||||
</Typography>
|
||||
|
||||
{/* Description */}
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#B0B0B0',
|
||||
mb: 3,
|
||||
flexGrow: 1,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{reward.description}
|
||||
</Typography>
|
||||
|
||||
{/* Price */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
icon={
|
||||
<StarIcon
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
color: '#FFD700',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={`${reward.price_stars.toLocaleString()} ⭐`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: canAfford
|
||||
? 'rgba(255, 215, 0, 0.15)'
|
||||
: 'rgba(244, 67, 54, 0.15)',
|
||||
color: canAfford ? '#FFD700' : '#F44336',
|
||||
border: canAfford
|
||||
? '1px solid rgba(255, 215, 0, 0.3)'
|
||||
: '1px solid rgba(244, 67, 54, 0.3)',
|
||||
fontWeight: 600,
|
||||
'& .MuiChip-icon': {
|
||||
color: canAfford ? '#FFD700' : '#F44336',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Action button */}
|
||||
<Box
|
||||
sx={{
|
||||
mt: 'auto',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
cursor: canAfford && inStock && isActive ? 'pointer' : 'default',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '-100%',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent)',
|
||||
transition: 'left 0.5s',
|
||||
},
|
||||
'&:hover::before': {
|
||||
left: '100%',
|
||||
},
|
||||
}}
|
||||
onClick={handleBuy}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2.5,
|
||||
textAlign: 'center',
|
||||
background: canAfford && inStock && isActive
|
||||
? 'linear-gradient(135deg, #4CAF50 0%, #66BB6A 100%)'
|
||||
: !canAfford
|
||||
? 'linear-gradient(135deg, #F44336 0%, #EF5350 100%)'
|
||||
: !inStock
|
||||
? 'linear-gradient(135deg, #666666 0%, #555555 100%)'
|
||||
: 'linear-gradient(135deg, #666666 0%, #555555 100%)',
|
||||
borderRadius: 1,
|
||||
boxShadow: canAfford && inStock && isActive
|
||||
? '0 4px 15px rgba(76, 175, 80, 0.3)'
|
||||
: 'none',
|
||||
transition: 'all 0.3s ease',
|
||||
'&:hover': {
|
||||
transform: canAfford && inStock && isActive ? 'translateY(-2px)' : 'none',
|
||||
boxShadow: canAfford && inStock && isActive
|
||||
? '0 6px 20px rgba(76, 175, 80, 0.4)'
|
||||
: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
|
||||
<ShoppingBagIcon
|
||||
sx={{
|
||||
color: canAfford && inStock && isActive ? '#FFFFFF' : '#FFFFFF',
|
||||
fontSize: 18,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: '#FFFFFF',
|
||||
fontWeight: 700,
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
{!canAfford
|
||||
? `Не хватает ${reward.price_stars - userStars} ⭐`
|
||||
: !inStock
|
||||
? 'Нет в наличии'
|
||||
: !isActive
|
||||
? 'Недоступно'
|
||||
: 'Купить'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
117
frontend/src/components/ui/HeaderProfile.tsx
Normal file
117
frontend/src/components/ui/HeaderProfile.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Avatar } from '@mui/material';
|
||||
import { Star as StarIcon } from '@mui/icons-material';
|
||||
|
||||
interface HeaderProfileProps {
|
||||
firstName: string;
|
||||
lastName?: string;
|
||||
avatar?: string;
|
||||
starsBalance: number;
|
||||
}
|
||||
|
||||
export const HeaderProfile: React.FC<HeaderProfileProps> = ({
|
||||
firstName,
|
||||
lastName,
|
||||
avatar,
|
||||
starsBalance,
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mb: 3,
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, rgba(255, 215, 0, 0.1) 0%, rgba(255, 215, 0, 0.05) 100%)',
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(255, 215, 0, 0.2)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '-50%',
|
||||
right: '-50%',
|
||||
width: '200%',
|
||||
height: '200%',
|
||||
background: 'radial-gradient(circle, rgba(255, 215, 0, 0.1) 0%, transparent 70%)',
|
||||
animation: 'pulse 4s ease-in-out infinite',
|
||||
},
|
||||
'@keyframes pulse': {
|
||||
'0%, 100%': {
|
||||
transform: 'scale(0.8)',
|
||||
opacity: 0.5,
|
||||
},
|
||||
'50%': {
|
||||
transform: 'scale(1.2)',
|
||||
opacity: 0.3,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Profile info */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, position: 'relative', zIndex: 1 }}>
|
||||
<Avatar
|
||||
src={avatar}
|
||||
alt={`${firstName} ${lastName || ''}`}
|
||||
sx={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
border: '3px solid #FFD700',
|
||||
boxShadow: '0 4px 15px rgba(255, 215, 0, 0.3)',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
transition: 'transform 0.3s ease',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
color: '#FFFFFF',
|
||||
fontWeight: 700,
|
||||
mb: 0.5,
|
||||
fontSize: 24,
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
Привет, {firstName}! 👋
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
background: 'rgba(255, 215, 0, 0.15)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '50px',
|
||||
border: '1px solid rgba(255, 215, 0, 0.3)',
|
||||
mt: 1,
|
||||
width: 'fit-content',
|
||||
}}
|
||||
>
|
||||
<StarIcon
|
||||
sx={{
|
||||
color: '#FFD700',
|
||||
fontSize: 24,
|
||||
animation: 'starPulse 2s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#FFD700',
|
||||
fontWeight: 700,
|
||||
fontSize: 20,
|
||||
}}
|
||||
>
|
||||
{starsBalance.toLocaleString()} ⭐
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
102
frontend/src/components/ui/QRScannerButton.tsx
Normal file
102
frontend/src/components/ui/QRScannerButton.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { Box, Button, Fab, useMediaQuery, useTheme } from '@mui/material';
|
||||
import { QrCodeScanner as QrCodeScannerIcon } from '@mui/icons-material';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export const QRScannerButton: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
|
||||
const handleScan = () => {
|
||||
navigate('/qr-scanner');
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
// FAB for mobile
|
||||
return (
|
||||
<Fab
|
||||
color="primary"
|
||||
onClick={handleScan}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 90,
|
||||
right: 20,
|
||||
width: 64,
|
||||
height: 64,
|
||||
backgroundColor: '#FFD700',
|
||||
color: '#000000',
|
||||
boxShadow: '0 6px 20px rgba(255, 215, 0, 0.4)',
|
||||
zIndex: 1000,
|
||||
'&:hover': {
|
||||
backgroundColor: '#FFC700',
|
||||
transform: 'scale(1.1)',
|
||||
boxShadow: '0 8px 25px rgba(255, 215, 0, 0.6)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'scale(0.95)',
|
||||
},
|
||||
animation: 'float 3s ease-in-out infinite',
|
||||
'@keyframes float': {
|
||||
'0%, 100%': {
|
||||
transform: 'translateY(0px)',
|
||||
},
|
||||
'50%': {
|
||||
transform: 'translateY(-5px)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<QrCodeScannerIcon sx={{ fontSize: 32 }} />
|
||||
</Fab>
|
||||
);
|
||||
}
|
||||
|
||||
// Large button for desktop/tablet
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 90,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleScan}
|
||||
startIcon={<QrCodeScannerIcon />}
|
||||
sx={{
|
||||
backgroundColor: '#FFD700',
|
||||
color: '#000000',
|
||||
fontWeight: 700,
|
||||
fontSize: 16,
|
||||
borderRadius: '50px',
|
||||
px: 4,
|
||||
py: 2,
|
||||
boxShadow: '0 6px 20px rgba(255, 215, 0, 0.4)',
|
||||
'&:hover': {
|
||||
backgroundColor: '#FFC700',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 25px rgba(255, 215, 0, 0.6)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(0px)',
|
||||
},
|
||||
animation: 'pulse 2s ease-in-out infinite',
|
||||
'@keyframes pulse': {
|
||||
'0%, 100%': {
|
||||
boxShadow: '0 6px 20px rgba(255, 215, 0, 0.4)',
|
||||
},
|
||||
'50%': {
|
||||
boxShadow: '0 8px 30px rgba(255, 215, 0, 0.6)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
Сканировать QR
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
74
frontend/src/components/ui/QuestionCard.tsx
Normal file
74
frontend/src/components/ui/QuestionCard.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { Typography, Card, CardContent } from '@mui/material';
|
||||
|
||||
interface QuestionCardProps {
|
||||
question: string;
|
||||
questionNumber: number;
|
||||
totalQuestions: number;
|
||||
}
|
||||
|
||||
export const QuestionCard: React.FC<QuestionCardProps> = ({
|
||||
question,
|
||||
questionNumber,
|
||||
totalQuestions,
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
mb: 4,
|
||||
background: 'linear-gradient(135deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%)',
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: 'linear-gradient(90deg, #FFD700, #FFC700, #FFD700)',
|
||||
animation: 'shine 3s ease-in-out infinite',
|
||||
},
|
||||
'&::after': {
|
||||
content: `"${questionNumber} / ${totalQuestions}"`,
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: '#FFD700',
|
||||
backgroundColor: 'rgba(255, 215, 0, 0.1)',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '50px',
|
||||
border: '1px solid rgba(255, 215, 0, 0.3)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent
|
||||
sx={{
|
||||
p: 4,
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
color: '#FFFFFF',
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.4,
|
||||
textAlign: 'center',
|
||||
fontSize: { xs: 18, sm: 20, md: 22 },
|
||||
animation: 'fadeIn 0.5s ease-out',
|
||||
}}
|
||||
>
|
||||
{question}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
186
frontend/src/components/ui/QuizProgress.tsx
Normal file
186
frontend/src/components/ui/QuizProgress.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
import React from 'react';
|
||||
import { Box, LinearProgress, Typography, Stack } from '@mui/material';
|
||||
import { AccessTime as AccessTimeIcon } from '@mui/icons-material';
|
||||
|
||||
interface QuizProgressProps {
|
||||
currentQuestion: number;
|
||||
totalQuestions: number;
|
||||
timeRemaining?: number;
|
||||
hasTimer: boolean;
|
||||
}
|
||||
|
||||
export const QuizProgress: React.FC<QuizProgressProps> = ({
|
||||
currentQuestion,
|
||||
totalQuestions,
|
||||
timeRemaining,
|
||||
hasTimer,
|
||||
}) => {
|
||||
const progress = (currentQuestion / totalQuestions) * 100;
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const timeColor = timeRemaining && timeRemaining <= 10 ? '#F44336' : '#FFD700';
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mb: 4,
|
||||
p: 3,
|
||||
background: 'linear-gradient(135deg, rgba(255, 215, 0, 0.1) 0%, rgba(255, 215, 0, 0.05) 100%)',
|
||||
borderRadius: 2,
|
||||
border: '1px solid rgba(255, 215, 0, 0.2)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '2px',
|
||||
background: 'linear-gradient(90deg, #FFD700, #FFC700, #FFD700)',
|
||||
animation: 'shine 2s ease-in-out infinite',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Progress info */}
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
justifyContent="space-between"
|
||||
alignItems={{ xs: 'flex-start', sm: 'center' }}
|
||||
spacing={2}
|
||||
mb={2}
|
||||
>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#FFFFFF',
|
||||
fontWeight: 600,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
Вопрос {currentQuestion} из {totalQuestions}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#B0B0B0',
|
||||
}}
|
||||
>
|
||||
Прогресс: {Math.round(progress)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{hasTimer && timeRemaining !== undefined && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
p: 1.5,
|
||||
backgroundColor: timeRemaining <= 10
|
||||
? 'rgba(244, 67, 54, 0.1)'
|
||||
: 'rgba(255, 215, 0, 0.1)',
|
||||
borderRadius: 1,
|
||||
border: timeRemaining <= 10
|
||||
? '1px solid rgba(244, 67, 54, 0.3)'
|
||||
: '1px solid rgba(255, 215, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
<AccessTimeIcon
|
||||
sx={{
|
||||
color: timeColor,
|
||||
fontSize: 20,
|
||||
animation: timeRemaining <= 10 ? 'pulse 1s ease-in-out infinite' : 'none',
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: timeColor,
|
||||
fontWeight: 700,
|
||||
fontSize: 18,
|
||||
animation: timeRemaining <= 10 ? 'pulse 1s ease-in-out infinite' : 'none',
|
||||
}}
|
||||
>
|
||||
{formatTime(timeRemaining)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Progress bar */}
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
borderRadius: 6,
|
||||
background: 'linear-gradient(90deg, #FFD700, #FFC700)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent)',
|
||||
animation: 'shine 2s ease-in-out infinite',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Question indicators */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 0.5,
|
||||
mt: 2,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: totalQuestions }, (_, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: index + 1 < currentQuestion
|
||||
? '#4CAF50'
|
||||
: index + 1 === currentQuestion
|
||||
? '#FFD700'
|
||||
: 'rgba(255, 255, 255, 0.1)',
|
||||
border: index + 1 === currentQuestion
|
||||
? '2px solid #FFD700'
|
||||
: '1px solid rgba(255, 255, 255, 0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: index + 1 === currentQuestion ? '#000000' : '#FFFFFF',
|
||||
transition: 'all 0.3s ease',
|
||||
animation: index + 1 === currentQuestion ? 'pulse 2s ease-in-out infinite' : 'none',
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
138
frontend/src/context/AuthContext.tsx
Normal file
138
frontend/src/context/AuthContext.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { User } from '../types';
|
||||
import { apiService } from '../services/api';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
login: (initData: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
isAuthenticated: boolean;
|
||||
isAdmin: boolean;
|
||||
updateUser: (userData: Partial<User>) => void;
|
||||
deepLinkAction: { type: string; value: string } | null;
|
||||
clearDeepLinkAction: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [deepLinkAction, setDeepLinkAction] = useState<{ type: string; value: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
if (apiService.isAuthenticated()) {
|
||||
try {
|
||||
const response = await apiService.getCurrentUser();
|
||||
if (response.success && response.data) {
|
||||
setUser(response.data);
|
||||
|
||||
// Check admin role
|
||||
const adminResponse = await apiService.checkAdminRole();
|
||||
if (adminResponse.success && adminResponse.data) {
|
||||
setIsAdmin(['admin', 'operator'].includes(adminResponse.data.role));
|
||||
}
|
||||
} else {
|
||||
apiService.clearAuthToken();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth initialization error:', error);
|
||||
apiService.clearAuthToken();
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
const login = async (initData: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await apiService.validateTelegramInitData(initData);
|
||||
|
||||
if (response.success) {
|
||||
apiService.setTelegramInitData(initData);
|
||||
|
||||
const userResponse = await apiService.getCurrentUser();
|
||||
|
||||
if (userResponse.success && userResponse.data) {
|
||||
setUser(userResponse.data);
|
||||
|
||||
// Check admin role
|
||||
const adminResponse = await apiService.checkAdminRole();
|
||||
if (adminResponse.success && adminResponse.data) {
|
||||
setIsAdmin(['admin', 'operator'].includes(adminResponse.data.role));
|
||||
}
|
||||
|
||||
// Handle deep link parameters from Telegram Web App
|
||||
if (window.Telegram?.WebApp?.initDataUnsafe?.start_param) {
|
||||
const startParam = window.Telegram.WebApp.initDataUnsafe.start_param;
|
||||
handleDeepLink(startParam);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateUser = (userData: Partial<User>) => {
|
||||
setUser(prev => prev ? { ...prev, ...userData } : null);
|
||||
};
|
||||
|
||||
const handleDeepLink = (startParam: string) => {
|
||||
// Parse start_param format: "reward_50", "quiz_123", "shop", "reward_789"
|
||||
const parts = startParam.split('_');
|
||||
const type = parts[0];
|
||||
const value = parts.slice(1).join('_');
|
||||
|
||||
if (['reward', 'quiz', 'shop'].includes(type)) {
|
||||
setDeepLinkAction({ type, value });
|
||||
}
|
||||
};
|
||||
|
||||
const clearDeepLinkAction = () => {
|
||||
setDeepLinkAction(null);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
apiService.clearAuthToken();
|
||||
setUser(null);
|
||||
setIsAdmin(false);
|
||||
setDeepLinkAction(null);
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
isAuthenticated: !!user,
|
||||
isAdmin,
|
||||
updateUser,
|
||||
deepLinkAction,
|
||||
clearDeepLinkAction,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
71
frontend/src/index.css
Normal file
71
frontend/src/index.css
Normal file
@ -0,0 +1,71 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap');
|
||||
@import './styles/animations.css';
|
||||
|
||||
:root {
|
||||
font-family: 'Roboto', 'Helvetica', 'Arial', sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: dark;
|
||||
color: #FFFFFF;
|
||||
background-color: #0F0F0F;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background-color: #0F0F0F;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
1232
frontend/src/pages/AdminPage.tsx
Normal file
1232
frontend/src/pages/AdminPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
251
frontend/src/pages/HomePage.tsx
Normal file
251
frontend/src/pages/HomePage.tsx
Normal file
@ -0,0 +1,251 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { apiService } from '../services/api';
|
||||
import { CardQuiz } from '../components/ui/CardQuiz';
|
||||
import { HeaderProfile } from '../components/ui/HeaderProfile';
|
||||
import { QRScannerButton } from '../components/ui/QRScannerButton';
|
||||
import { GridItem } from '../components/GridItem';
|
||||
import type { Quiz } from '../types';
|
||||
|
||||
interface QuizStatus {
|
||||
can_repeat: boolean;
|
||||
next_available_at?: string;
|
||||
}
|
||||
|
||||
export const HomePage: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [quizzes, setQuizzes] = useState<Quiz[]>([]);
|
||||
const [quizStatuses, setQuizStatuses] = useState<{ [key: number]: QuizStatus }>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const checkQuizStatus = async (quizId: number) => {
|
||||
try {
|
||||
const response = await apiService.canRepeatQuiz(quizId);
|
||||
if (response.success && response.data) {
|
||||
setQuizStatuses(prev => ({
|
||||
...prev,
|
||||
[quizId]: response.data
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error checking quiz status:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchQuizzes = async () => {
|
||||
// Debug: Check Telegram Web App status and initData
|
||||
const telegramDebug = {
|
||||
windowExists: !!window,
|
||||
telegramExists: !!window.Telegram,
|
||||
webAppExists: !!window.Telegram?.WebApp,
|
||||
initData: window.Telegram?.WebApp?.initData,
|
||||
initDataUnsafe: window.Telegram?.WebApp?.initDataUnsafe,
|
||||
storedInitData: localStorage.getItem('telegram_init_data'),
|
||||
windowLocation: window.location.href,
|
||||
userAgent: navigator.userAgent,
|
||||
referrer: document.referrer
|
||||
};
|
||||
|
||||
console.log('Telegram Debug:', telegramDebug);
|
||||
|
||||
// Show comprehensive debug info
|
||||
// alert(`Telegram Debug Info:\n${JSON.stringify(telegramDebug, null, 2)}`);
|
||||
|
||||
// Check if we have initData and store it if not already stored
|
||||
if (window.Telegram?.WebApp?.initData && !localStorage.getItem('telegram_init_data')) {
|
||||
localStorage.setItem('telegram_init_data', window.Telegram.WebApp.initData);
|
||||
console.log('Stored Telegram initData');
|
||||
}
|
||||
try {
|
||||
const response = await apiService.getAllQuizzes();
|
||||
if (response.success && response.data) {
|
||||
setQuizzes(response.data);
|
||||
|
||||
// Check status for each quiz that allows repetition
|
||||
response.data.forEach(quiz => {
|
||||
if (quiz.can_repeat) {
|
||||
checkQuizStatus(quiz.id);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setError('Не удалось загрузить викторины');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching quizzes:', err);
|
||||
|
||||
// Show detailed error information for debugging
|
||||
const errorDetails = {
|
||||
message: err.message,
|
||||
status: err.response?.status,
|
||||
statusText: err.response?.statusText,
|
||||
data: err.response?.data,
|
||||
headers: err.config?.headers,
|
||||
url: err.config?.url
|
||||
};
|
||||
|
||||
console.error('Error Details:', errorDetails);
|
||||
|
||||
if (err.response?.status === 401) {
|
||||
setError(`Ошибка авторизации: ${err.response?.data?.message || 'Unknown error'}`);
|
||||
} else {
|
||||
setError(`Ошибка (${err.response?.status}): ${err.message}`);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchQuizzes();
|
||||
}, []);
|
||||
|
||||
const handleStartQuiz = (quizId: number) => {
|
||||
navigate(`/quiz/${quizId}`);
|
||||
};
|
||||
|
||||
const formatCooldownTime = (nextAvailableAt: string) => {
|
||||
const next = new Date(nextAvailableAt);
|
||||
const now = new Date();
|
||||
const diff = next.getTime() - now.getTime();
|
||||
|
||||
if (diff <= 0) return 'Доступно сейчас';
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 0) {
|
||||
return `Через ${hours}ч ${minutes}м`;
|
||||
}
|
||||
return `Через ${minutes}м`;
|
||||
};
|
||||
|
||||
const getQuizStatus = (quiz: Quiz) => {
|
||||
const status = quizStatuses[quiz.id];
|
||||
|
||||
if (!quiz.can_repeat) {
|
||||
return {
|
||||
canStart: true,
|
||||
isCompleted: false,
|
||||
cooldownTime: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (status && !status.can_repeat && status.next_available_at) {
|
||||
return {
|
||||
canStart: false,
|
||||
isCompleted: true,
|
||||
cooldownTime: formatCooldownTime(status.next_available_at),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
canStart: true,
|
||||
isCompleted: false,
|
||||
cooldownTime: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
|
||||
<CircularProgress sx={{ color: '#FFD700' }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{
|
||||
mt: 4,
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
color: '#ffffff',
|
||||
border: '1px solid #f44336',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Profile Header */}
|
||||
{user && (
|
||||
<HeaderProfile
|
||||
firstName={user.first_name}
|
||||
lastName={user.last_name}
|
||||
avatar={user.photo_url}
|
||||
starsBalance={user.stars_balance || 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Quizzes Grid */}
|
||||
<Box sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(2, 1fr)',
|
||||
md: 'repeat(3, 1fr)'
|
||||
},
|
||||
gap: 3,
|
||||
}}>
|
||||
{quizzes.map((quiz) => {
|
||||
const quizStatus = getQuizStatus(quiz);
|
||||
|
||||
return (
|
||||
<GridItem xs={12} sm={6} md={4} key={quiz.id}>
|
||||
<CardQuiz
|
||||
quiz={quiz}
|
||||
onStart={handleStartQuiz}
|
||||
isCompleted={quizStatus.isCompleted}
|
||||
cooldownTime={quizStatus.cooldownTime}
|
||||
canStart={quizStatus.canStart}
|
||||
/>
|
||||
</GridItem>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* Empty State */}
|
||||
{quizzes.length === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
mt: 8,
|
||||
p: 4,
|
||||
background: 'rgba(255, 255, 255, 0.02)',
|
||||
borderRadius: 2,
|
||||
border: '1px dashed rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ color: '#888', mb: 2 }}
|
||||
>
|
||||
Пока нет доступных викторин
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: '#666', mb: 3 }}
|
||||
>
|
||||
Загляните позже или отсканируйте QR-код для получения звёзд
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* QR Scanner Button */}
|
||||
<QRScannerButton />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
134
frontend/src/pages/LoginPage.tsx
Normal file
134
frontend/src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
|
||||
export const LoginPage: React.FC = () => {
|
||||
const { login } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleTelegramAuth = async () => {
|
||||
if (window.Telegram?.WebApp?.initData) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
window.Telegram.WebApp.ready();
|
||||
window.Telegram.WebApp.expand();
|
||||
|
||||
const success = await login(window.Telegram.WebApp.initData);
|
||||
|
||||
if (!success) {
|
||||
setError('Ошибка аутентификации. Пожалуйста, попробуйте снова.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Telegram auth error:', err);
|
||||
setError('Произошла ошибка при подключении к Telegram.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
setError('Пожалуйста, откройте приложение через Telegram.');
|
||||
}
|
||||
};
|
||||
|
||||
handleTelegramAuth();
|
||||
}, [login]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100vh',
|
||||
padding: 2,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
color: '#FFD700',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
🌟 Звёздные Викторины
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#ffffff',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
Проходите викторины, сканируйте QR-коды и получайте звёзды!
|
||||
</Typography>
|
||||
|
||||
{loading && (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<CircularProgress sx={{ color: '#FFD700' }} />
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: '#ffffff', mt: 2 }}
|
||||
>
|
||||
Подключение к Telegram...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{
|
||||
mt: 4,
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
color: '#ffffff',
|
||||
border: '1px solid #f44336',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !error && !window.Telegram?.WebApp?.initData && (
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: '#ffffff', mb: 2 }}
|
||||
>
|
||||
Для продолжения необходимо открыть приложение через Telegram
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
sx={{
|
||||
backgroundColor: '#FFD700',
|
||||
color: '#000000',
|
||||
'&:hover': {
|
||||
backgroundColor: '#FFC700',
|
||||
},
|
||||
}}
|
||||
onClick={() => {
|
||||
if (window.Telegram?.WebApp) {
|
||||
window.Telegram.WebApp.close();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Закрыть
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
407
frontend/src/pages/ProfilePage.tsx
Normal file
407
frontend/src/pages/ProfilePage.tsx
Normal file
@ -0,0 +1,407 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Tab,
|
||||
Tabs,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Person,
|
||||
Star,
|
||||
History,
|
||||
ShoppingCart,
|
||||
AddCircle,
|
||||
RemoveCircle,
|
||||
} from '@mui/icons-material';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { apiService } from '../services/api';
|
||||
import type { Transaction, Purchase } from '../types';
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => {
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`profile-tabpanel-${index}`}
|
||||
aria-labelledby={`profile-tab-${index}`}
|
||||
>
|
||||
{value === index && <Box sx={{ mt: 2 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProfilePage: React.FC = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const [value, setValue] = useState(0);
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [purchases, setPurchases] = useState<Purchase[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [transactionsResponse, purchasesResponse] = await Promise.all([
|
||||
apiService.getUserTransactions(),
|
||||
apiService.getUserPurchases(),
|
||||
]);
|
||||
|
||||
if (transactionsResponse.success && transactionsResponse.data) {
|
||||
setTransactions(transactionsResponse.data);
|
||||
}
|
||||
|
||||
if (purchasesResponse.success && purchasesResponse.data) {
|
||||
setPurchases(purchasesResponse.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching profile data:', err);
|
||||
setError('Произошла ошибка при загрузке данных');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||
setValue(newValue);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
|
||||
<CircularProgress sx={{ color: '#FFD700' }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{
|
||||
mt: 4,
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
color: '#ffffff',
|
||||
border: '1px solid #f44336',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Profile Header */}
|
||||
<Card
|
||||
sx={{
|
||||
backgroundColor: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: 2,
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 3 }}>
|
||||
{user?.photo_url ? (
|
||||
<img
|
||||
src={user.photo_url}
|
||||
alt="User Avatar"
|
||||
style={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
objectFit: 'cover',
|
||||
marginRight: 16,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#FFD700',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mr: 3,
|
||||
}}
|
||||
>
|
||||
<Person sx={{ fontSize: 40, color: '#000000' }} />
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
color: '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
{user?.first_name} {user?.last_name}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: '#888', mb: 1 }}
|
||||
>
|
||||
@{user?.username}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: '#666' }}
|
||||
>
|
||||
ID: {user?.telegram_id}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: 'rgba(255, 215, 0, 0.1)',
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
border: '1px solid #FFD700',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Star sx={{ color: '#FFD700' }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ color: '#FFD700', fontWeight: 'bold' }}
|
||||
>
|
||||
{user?.stars_balance} ⭐
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleLogout}
|
||||
sx={{
|
||||
borderColor: '#f44336',
|
||||
color: '#f44336',
|
||||
'&:hover': {
|
||||
borderColor: '#f44336',
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Выйти
|
||||
</Button>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
<Box sx={{ borderBottom: 1, borderColor: '#333' }}>
|
||||
<Tabs
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
sx={{
|
||||
'& .MuiTab-root': {
|
||||
color: '#888',
|
||||
'&.Mui-selected': {
|
||||
color: '#FFD700',
|
||||
},
|
||||
},
|
||||
'& .MuiTabs-indicator': {
|
||||
backgroundColor: '#FFD700',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
icon={<History />}
|
||||
label="История"
|
||||
id="profile-tab-0"
|
||||
aria-controls="profile-tabpanel-0"
|
||||
/>
|
||||
<Tab
|
||||
icon={<ShoppingCart />}
|
||||
label="Покупки"
|
||||
id="profile-tab-1"
|
||||
aria-controls="profile-tabpanel-1"
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Tab Panels */}
|
||||
<TabPanel value={value} index={0}>
|
||||
<Card
|
||||
sx={{
|
||||
backgroundColor: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<List>
|
||||
{transactions.map((transaction, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
sx={{
|
||||
borderBottom: index < transactions.length - 1 ? '1px solid #333' : 'none',
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{transaction.type === 'earned' ? (
|
||||
<AddCircle sx={{ color: '#4CAF50' }} />
|
||||
) : (
|
||||
<RemoveCircle sx={{ color: '#f44336' }} />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography sx={{ color: '#ffffff' }}>
|
||||
{transaction.description}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Typography sx={{ color: '#666', fontSize: '0.875rem' }}>
|
||||
{formatDate(transaction.created_at)}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<Chip
|
||||
label={`${transaction.type === 'earned' ? '+' : ''}${transaction.amount} ⭐`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor:
|
||||
transaction.type === 'earned'
|
||||
? 'rgba(76, 175, 80, 0.2)'
|
||||
: 'rgba(244, 67, 54, 0.2)',
|
||||
color: transaction.type === 'earned' ? '#4CAF50' : '#f44336',
|
||||
border: transaction.type === 'earned'
|
||||
? '1px solid #4CAF50'
|
||||
: '1px solid #f44336',
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
{transactions.length === 0 && (
|
||||
<Box sx={{ textAlign: 'center', p: 4 }}>
|
||||
<History sx={{ fontSize: 48, color: '#666', mb: 2 }} />
|
||||
<Typography sx={{ color: '#888' }}>
|
||||
Пока нет транзакций
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={value} index={1}>
|
||||
<Card
|
||||
sx={{
|
||||
backgroundColor: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<List>
|
||||
{purchases.map((purchase, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
sx={{
|
||||
borderBottom: index < purchases.length - 1 ? '1px solid #333' : 'none',
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<ShoppingCart sx={{ color: '#FFD700' }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Typography sx={{ color: '#ffffff' }}>
|
||||
Покупка #{purchase.id}
|
||||
</Typography>
|
||||
}
|
||||
secondary={
|
||||
<Typography sx={{ color: '#666', fontSize: '0.875rem' }}>
|
||||
{formatDate(purchase.purchased_at)}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Chip
|
||||
label={`${purchase.stars_spent} ⭐`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255, 215, 0, 0.2)',
|
||||
color: '#FFD700',
|
||||
border: '1px solid #FFD700',
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
label={purchase.status}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor:
|
||||
purchase.status === 'delivered'
|
||||
? 'rgba(76, 175, 80, 0.2)'
|
||||
: purchase.status === 'pending'
|
||||
? 'rgba(255, 193, 7, 0.2)'
|
||||
: 'rgba(244, 67, 54, 0.2)',
|
||||
color:
|
||||
purchase.status === 'delivered'
|
||||
? '#4CAF50'
|
||||
: purchase.status === 'pending'
|
||||
? '#FFC700'
|
||||
: '#f44336',
|
||||
border:
|
||||
purchase.status === 'delivered'
|
||||
? '1px solid #4CAF50'
|
||||
: purchase.status === 'pending'
|
||||
? '1px solid #FFC700'
|
||||
: '1px solid #f44336',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
{purchases.length === 0 && (
|
||||
<Box sx={{ textAlign: 'center', p: 4 }}>
|
||||
<ShoppingCart sx={{ fontSize: 48, color: '#666', mb: 2 }} />
|
||||
<Typography sx={{ color: '#888' }}>
|
||||
Пока нет покупок
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
</TabPanel>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
488
frontend/src/pages/QRScannerPage.tsx
Normal file
488
frontend/src/pages/QRScannerPage.tsx
Normal file
@ -0,0 +1,488 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Modal,
|
||||
Paper,
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@mui/material';
|
||||
import { QrCodeScanner, Close, CheckCircle, Error } from '@mui/icons-material';
|
||||
import { Html5Qrcode } from 'html5-qrcode';
|
||||
import { apiService } from '../services/api';
|
||||
|
||||
interface QRResult {
|
||||
type: string;
|
||||
value: any;
|
||||
message: string;
|
||||
quizData?: any;
|
||||
}
|
||||
|
||||
export const QRScannerPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<QRResult | null>(null);
|
||||
const [showResult, setShowResult] = useState(false);
|
||||
const scannerRef = useRef<Html5Qrcode | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scannerRef.current) {
|
||||
try {
|
||||
scannerRef.current.clear();
|
||||
} catch (err) {
|
||||
console.error('Error clearing scanner:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startScanning = async () => {
|
||||
if (scannerRef.current) {
|
||||
try {
|
||||
await scannerRef.current.clear();
|
||||
} catch (err) {
|
||||
console.error('Error clearing scanner:', err);
|
||||
}
|
||||
}
|
||||
|
||||
setScanning(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const scanner = new Html5Qrcode('qr-reader');
|
||||
scannerRef.current = scanner;
|
||||
|
||||
const config = {
|
||||
fps: 10,
|
||||
qrbox: { width: 250, height: 250 },
|
||||
};
|
||||
|
||||
await scanner.start(
|
||||
{ facingMode: 'environment' },
|
||||
config,
|
||||
async (decodedText: string) => {
|
||||
await handleQRScan(decodedText);
|
||||
},
|
||||
(_errorMessage: string) => {
|
||||
// Handle scan errors silently
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Error starting scanner:', err);
|
||||
setError('Не удалось получить доступ к камере. Пожалуйста, проверьте разрешения.');
|
||||
setScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stopScanning = async () => {
|
||||
if (scannerRef.current) {
|
||||
try {
|
||||
await scannerRef.current.clear();
|
||||
} catch (err) {
|
||||
console.error('Error stopping scanner:', err);
|
||||
}
|
||||
}
|
||||
setScanning(false);
|
||||
};
|
||||
|
||||
const handleQRScan = async (decodedText: string) => {
|
||||
await stopScanning();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await apiService.validateQR(decodedText);
|
||||
if (response.success && response.data) {
|
||||
// Transform backend response types to frontend format
|
||||
const transformedData: any = {
|
||||
type: response.data.type.toLowerCase(), // Convert "REWARD" -> "reward", "OPEN_QUIZ" -> "open_quiz"
|
||||
};
|
||||
|
||||
// Handle different response structures
|
||||
if (response.data.type === 'REWARD') {
|
||||
transformedData.value = response.data.data.amount;
|
||||
transformedData.message = `Вы получили ${response.data.data.amount} ⭐`;
|
||||
} else if (response.data.type === 'OPEN_QUIZ') {
|
||||
transformedData.value = response.data.data.id;
|
||||
transformedData.message = `Викторина: ${response.data.data.title}`;
|
||||
transformedData.quizData = response.data.data; // Store full quiz data
|
||||
} else if (response.data.type === 'SHOP_ACTION') {
|
||||
transformedData.value = response.data.data.action;
|
||||
transformedData.message = `Магазин: ${response.data.data.action}`;
|
||||
}
|
||||
|
||||
setResult(transformedData);
|
||||
setShowResult(true);
|
||||
} else {
|
||||
setError(response.message || 'Недействительный QR-код');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error validating QR:', err);
|
||||
setError('Произошла ошибка при проверке QR-кода');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResultClose = () => {
|
||||
setShowResult(false);
|
||||
setResult(null);
|
||||
navigate('/home');
|
||||
};
|
||||
|
||||
const handleGoToQuiz = (quizId: number) => {
|
||||
setShowResult(false);
|
||||
setResult(null);
|
||||
navigate(`/quiz/${quizId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Header */}
|
||||
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
color: '#FFD700',
|
||||
fontWeight: 'bold',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
📷 QR-сканер
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: '#888' }}
|
||||
>
|
||||
Наведите камеру на QR-код для сканирования
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Scanner Container */}
|
||||
<Box sx={{ position: 'relative', mb: 4 }}>
|
||||
<div id="qr-reader" style={{ width: '100%', height: '300px' }} />
|
||||
|
||||
{scanning && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 250,
|
||||
height: 250,
|
||||
border: '2px solid #FFD700',
|
||||
borderRadius: 2,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
left: -2,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderTop: '3px solid #FFD700',
|
||||
borderLeft: '3px solid #FFD700',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
right: -2,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderTop: '3px solid #FFD700',
|
||||
borderRight: '3px solid #FFD700',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
left: -2,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderBottom: '3px solid #FFD700',
|
||||
borderLeft: '3px solid #FFD700',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderBottom: '3px solid #FFD700',
|
||||
borderRight: '3px solid #FFD700',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Controls */}
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
|
||||
{!scanning ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={startScanning}
|
||||
startIcon={<QrCodeScanner />}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
backgroundColor: '#FFD700',
|
||||
color: '#000000',
|
||||
fontWeight: 'bold',
|
||||
'&:hover': {
|
||||
backgroundColor: '#FFC700',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{loading ? <CircularProgress size={20} /> : 'Начать сканирование'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={stopScanning}
|
||||
startIcon={<Close />}
|
||||
sx={{
|
||||
borderColor: '#f44336',
|
||||
color: '#f44336',
|
||||
'&:hover': {
|
||||
borderColor: '#f44336',
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Остановить
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{
|
||||
mt: 4,
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
color: '#ffffff',
|
||||
border: '1px solid #f44336',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<Card
|
||||
sx={{
|
||||
backgroundColor: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: 2,
|
||||
mt: 4,
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
Как использовать:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: '#888', mb: 1 }}
|
||||
>
|
||||
1. Нажмите "Начать сканирование"
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: '#888', mb: 1 }}
|
||||
>
|
||||
2. Разрешите доступ к камере
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: '#888', mb: 1 }}
|
||||
>
|
||||
3. Наведите камеру на QR-код
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: '#888' }}
|
||||
>
|
||||
4. Дождитесь результата сканирования
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Result Modal */}
|
||||
<Modal
|
||||
open={showResult}
|
||||
onClose={handleResultClose}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
sx={{
|
||||
backgroundColor: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: 2,
|
||||
p: 4,
|
||||
maxWidth: 400,
|
||||
width: '90%',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{result?.type === 'reward' && (
|
||||
<>
|
||||
<CheckCircle sx={{ fontSize: 64, color: '#4CAF50', mb: 2 }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#4CAF50',
|
||||
fontWeight: 'bold',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
QR-код успешно отсканирован!
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: '#ffffff', mb: 1 }}
|
||||
>
|
||||
Вы получили {result.value} ⭐
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: '#888', mb: 3 }}
|
||||
>
|
||||
{result.message}
|
||||
</Typography>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={handleResultClose}
|
||||
sx={{
|
||||
backgroundColor: '#4CAF50',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
>
|
||||
Отлично!
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{result?.type === 'open_quiz' && (
|
||||
<>
|
||||
<CheckCircle sx={{ fontSize: 64, color: '#FFD700', mb: 2 }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#FFD700',
|
||||
fontWeight: 'bold',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
Викторина найдена!
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: '#ffffff', mb: 1 }}
|
||||
>
|
||||
{result.message}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: '#888', mb: 3 }}
|
||||
>
|
||||
Готовы пройти викторину?
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onClick={handleResultClose}
|
||||
sx={{
|
||||
borderColor: '#333',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
>
|
||||
Позже
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={() => handleGoToQuiz(result.value)}
|
||||
sx={{
|
||||
backgroundColor: '#FFD700',
|
||||
color: '#000000',
|
||||
}}
|
||||
>
|
||||
Начать
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{result?.type === 'error' && (
|
||||
<>
|
||||
<Error sx={{ fontSize: 64, color: '#f44336', mb: 2 }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#f44336',
|
||||
fontWeight: 'bold',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
Ошибка!
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: '#ffffff', mb: 3 }}
|
||||
>
|
||||
{result.message}
|
||||
</Typography>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={handleResultClose}
|
||||
sx={{
|
||||
backgroundColor: '#f44336',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
>
|
||||
Закрыть
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
282
frontend/src/pages/QuizPage.tsx
Normal file
282
frontend/src/pages/QuizPage.tsx
Normal file
@ -0,0 +1,282 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { apiService } from '../services/api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { QuestionCard } from '../components/ui/QuestionCard';
|
||||
import { AnswerOption } from '../components/ui/AnswerOption';
|
||||
import type { Quiz, Question, UserAnswer } from '../types';
|
||||
|
||||
export const QuizPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { user, updateUser } = useAuth();
|
||||
const [quiz, setQuiz] = useState<Quiz | null>(null);
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<UserAnswer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchQuiz = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const response = await apiService.getQuizById(parseInt(id));
|
||||
console.log('Quiz response:', response);
|
||||
if (response.success && response.data) {
|
||||
setQuiz(response.data);
|
||||
// Initialize answers
|
||||
const initialAnswers = response.data.questions?.map((q: Question) => ({
|
||||
question_id: q.id,
|
||||
option_ids: [],
|
||||
})) || [];
|
||||
console.log('Initial answers:', initialAnswers);
|
||||
setAnswers(initialAnswers);
|
||||
} else {
|
||||
setError('Не удалось загрузить викторину');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching quiz:', err);
|
||||
setError('Произошла ошибка при загрузке викторины');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchQuiz();
|
||||
}, [id]);
|
||||
|
||||
const handleAnswerChange = (questionId: number, optionId: string, isMultiple: boolean) => {
|
||||
console.log('handleAnswerChange called:', { questionId, optionId, isMultiple });
|
||||
|
||||
setAnswers(prev => {
|
||||
// Create deep copy to ensure immutability
|
||||
const newAnswers = prev.map(answer => ({
|
||||
...answer,
|
||||
option_ids: [...answer.option_ids]
|
||||
}));
|
||||
|
||||
const answerIndex = newAnswers.findIndex(a => a.question_id === questionId);
|
||||
|
||||
console.log('Current answers:', prev);
|
||||
console.log('Answer index:', answerIndex);
|
||||
|
||||
if (answerIndex === -1) return prev;
|
||||
|
||||
const optionIdNum = parseInt(optionId);
|
||||
|
||||
if (isMultiple) {
|
||||
const currentOptions = newAnswers[answerIndex].option_ids;
|
||||
const optionIndex = currentOptions.indexOf(optionIdNum);
|
||||
|
||||
if (optionIndex === -1) {
|
||||
currentOptions.push(optionIdNum);
|
||||
} else {
|
||||
currentOptions.splice(optionIndex, 1);
|
||||
}
|
||||
} else {
|
||||
newAnswers[answerIndex].option_ids = [optionIdNum];
|
||||
}
|
||||
|
||||
console.log('Updated answers:', newAnswers);
|
||||
return newAnswers;
|
||||
});
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (quiz && currentQuestionIndex < quiz.questions!.length - 1) {
|
||||
setCurrentQuestionIndex(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentQuestionIndex > 0) {
|
||||
setCurrentQuestionIndex(prev => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!quiz || !id) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await apiService.submitQuiz(parseInt(id), { answers });
|
||||
if (response.success && response.data) {
|
||||
// Update user balance with earned stars
|
||||
if (response.data.stars_earned > 0) {
|
||||
updateUser({
|
||||
stars_balance: (user?.stars_balance || 0) + response.data.stars_earned
|
||||
});
|
||||
}
|
||||
|
||||
// Navigate to results page
|
||||
navigate('/quiz-result', {
|
||||
state: {
|
||||
result: response.data,
|
||||
quizTitle: quiz.title
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setError('Не удалось отправить ответы');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error submitting quiz:', err);
|
||||
setError('Произошла ошибка при отправке ответов');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
|
||||
<CircularProgress sx={{ color: '#FFD700' }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !quiz) {
|
||||
return (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{
|
||||
mt: 4,
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
color: '#ffffff',
|
||||
border: '1px solid #f44336',
|
||||
}}
|
||||
>
|
||||
{error || 'Викторина не найдена'}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const currentQuestion = quiz.questions?.[currentQuestionIndex];
|
||||
const currentAnswer = answers.find(a => a.question_id === currentQuestion?.id);
|
||||
const isLastQuestion = currentQuestionIndex === quiz.questions!.length - 1;
|
||||
const canProceed = currentAnswer?.option_ids && currentAnswer.option_ids.length > 0;
|
||||
|
||||
console.log('Current state:', {
|
||||
currentQuestion,
|
||||
currentAnswer,
|
||||
answers,
|
||||
canProceed
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
color: '#FFD700',
|
||||
fontWeight: 'bold',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
{quiz.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
color: '#888',
|
||||
}}
|
||||
>
|
||||
Вопрос {currentQuestionIndex + 1} из {quiz.questions?.length}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Question Card */}
|
||||
<QuestionCard
|
||||
question={currentQuestion?.text || ''}
|
||||
questionNumber={currentQuestionIndex + 1}
|
||||
totalQuestions={quiz.questions?.length || 0}
|
||||
/>
|
||||
|
||||
{/* Answer Options */}
|
||||
<Box sx={{ mt: 3, pointerEvents: 'auto' }}>
|
||||
{currentQuestion?.options.map((option) => (
|
||||
<AnswerOption
|
||||
key={option.id}
|
||||
id={option.id.toString()}
|
||||
text={option.text}
|
||||
type={currentQuestion.type}
|
||||
isSelected={currentAnswer?.option_ids.includes(option.id) || false}
|
||||
onSelect={(optionId) => {
|
||||
console.log('AnswerOption onSelect called:', optionId);
|
||||
if (currentQuestion) {
|
||||
handleAnswerChange(currentQuestion.id, optionId, currentQuestion.type === 'multiple');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handlePrevious}
|
||||
disabled={currentQuestionIndex === 0}
|
||||
sx={{
|
||||
borderColor: '#333',
|
||||
color: '#ffffff',
|
||||
'&:hover': {
|
||||
borderColor: '#FFD700',
|
||||
color: '#FFD700',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
{!isLastQuestion ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed}
|
||||
sx={{
|
||||
backgroundColor: '#FFD700',
|
||||
color: '#000000',
|
||||
'&:hover': {
|
||||
backgroundColor: '#FFC700',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Далее
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canProceed || submitting}
|
||||
sx={{
|
||||
backgroundColor: '#4CAF50',
|
||||
color: '#ffffff',
|
||||
'&:hover': {
|
||||
backgroundColor: '#45a049',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{submitting ? <CircularProgress size={20} /> : 'Завершить'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
250
frontend/src/pages/QuizResultPage.tsx
Normal file
250
frontend/src/pages/QuizResultPage.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { EmojiEvents, Star, Home } from '@mui/icons-material';
|
||||
|
||||
interface ResultData {
|
||||
score: number;
|
||||
total_questions: number;
|
||||
stars_earned: number;
|
||||
correct_answers: number;
|
||||
}
|
||||
|
||||
interface LocationState {
|
||||
result: ResultData;
|
||||
quizTitle: string;
|
||||
}
|
||||
|
||||
export const QuizResultPage: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const state = location.state as LocationState;
|
||||
|
||||
useEffect(() => {
|
||||
if (!state) {
|
||||
navigate('/home');
|
||||
return;
|
||||
}
|
||||
}, [state, navigate]);
|
||||
|
||||
const handleGoHome = () => {
|
||||
navigate('/home');
|
||||
};
|
||||
|
||||
const handleGoToShop = () => {
|
||||
navigate('/shop');
|
||||
};
|
||||
|
||||
if (!state) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
|
||||
<CircularProgress sx={{ color: '#FFD700' }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const { result, quizTitle } = state;
|
||||
const percentage = result.total_questions > 0
|
||||
? Math.round((result.correct_answers / result.total_questions) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Result Header */}
|
||||
<Box sx={{ textAlign: 'center', mb: 4 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
color: '#FFD700',
|
||||
fontWeight: 'bold',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
🎉 Викторина завершена!
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#ffffff',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
{quizTitle}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Result Card */}
|
||||
<Card
|
||||
sx={{
|
||||
backgroundColor: 'linear-gradient(135deg, rgba(255, 215, 0, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%)',
|
||||
border: '2px solid #FFD700',
|
||||
borderRadius: 2,
|
||||
mb: 4,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
{/* Score Circle */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#FFD700',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto 3',
|
||||
border: '4px solid #ffffff',
|
||||
boxShadow: '0 0 20px rgba(255, 215, 0, 0.5)',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h3"
|
||||
sx={{
|
||||
color: '#000000',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{percentage}%
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Score Details */}
|
||||
<Box sx={{ textAlign: 'center', mb: 3 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#ffffff',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
{result.correct_answers} из {result.total_questions} правильных ответов
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: '#888' }}
|
||||
>
|
||||
Набрано очков: {result.score}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Stars Earned */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1,
|
||||
backgroundColor: 'rgba(255, 215, 0, 0.2)',
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
border: '1px solid #FFD700',
|
||||
}}
|
||||
>
|
||||
<Star sx={{ color: '#FFD700', fontSize: 24 }} />
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
color: '#FFD700',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
+{result.stars_earned} ⭐ начислено!
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Performance Message */}
|
||||
<Card
|
||||
sx={{
|
||||
backgroundColor: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: 2,
|
||||
mb: 4,
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
|
||||
<EmojiEvents
|
||||
sx={{
|
||||
color: percentage >= 80 ? '#FFD700' : percentage >= 60 ? '#4CAF50' : '#FF9800',
|
||||
fontSize: 32,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{percentage >= 80
|
||||
? 'Отличный результат! 🏆'
|
||||
: percentage >= 60
|
||||
? 'Хорошая работа! 👍'
|
||||
: 'Продолжайте тренироваться! 💪'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: '#888' }}
|
||||
>
|
||||
{percentage >= 80
|
||||
? 'Вы настоящий знаток! Продолжайте в том же духе.'
|
||||
: percentage >= 60
|
||||
? 'Неплохой результат! С каждым разом будет лучше.'
|
||||
: 'Практика делает мастера! Попробуйте еще раз.'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box sx={{ display: 'flex', gap: 2, flexDirection: 'column' }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={handleGoToShop}
|
||||
startIcon={<Star />}
|
||||
sx={{
|
||||
backgroundColor: '#FFD700',
|
||||
color: '#000000',
|
||||
fontWeight: 'bold',
|
||||
'&:hover': {
|
||||
backgroundColor: '#FFC700',
|
||||
},
|
||||
}}
|
||||
>
|
||||
В магазин за призами
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onClick={handleGoHome}
|
||||
startIcon={<Home />}
|
||||
sx={{
|
||||
borderColor: '#333',
|
||||
color: '#ffffff',
|
||||
'&:hover': {
|
||||
borderColor: '#FFD700',
|
||||
color: '#FFD700',
|
||||
},
|
||||
}}
|
||||
>
|
||||
К викторинам
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
400
frontend/src/pages/ShopPage.tsx
Normal file
400
frontend/src/pages/ShopPage.tsx
Normal file
@ -0,0 +1,400 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Button,
|
||||
Grid,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Modal,
|
||||
} from '@mui/material';
|
||||
import { GridItem } from '../components/GridItem';
|
||||
import { ShoppingBag, Inventory, LocalShipping, Code } from '@mui/icons-material';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { apiService } from '../services/api';
|
||||
import type { Reward } from '../types';
|
||||
|
||||
export const ShopPage: React.FC = () => {
|
||||
const { user, updateUser } = useAuth();
|
||||
const [rewards, setRewards] = useState<Reward[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedReward, setSelectedReward] = useState<Reward | null>(null);
|
||||
const [purchaseModalOpen, setPurchaseModalOpen] = useState(false);
|
||||
const [instructionModalOpen, setInstructionModalOpen] = useState(false);
|
||||
const [purchasing, setPurchasing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRewards = async () => {
|
||||
try {
|
||||
const response = await apiService.getAllRewards();
|
||||
if (response.success && response.data) {
|
||||
setRewards(response.data);
|
||||
} else {
|
||||
setError('Не удалось загрузить призы');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching rewards:', err);
|
||||
setError('Произошла ошибка при загрузке призов');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRewards();
|
||||
}, []);
|
||||
|
||||
const handlePurchase = async (reward: Reward) => {
|
||||
setSelectedReward(reward);
|
||||
setPurchaseModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmPurchase = async () => {
|
||||
if (!selectedReward) return;
|
||||
|
||||
setPurchasing(true);
|
||||
try {
|
||||
const response = await apiService.purchaseReward(selectedReward.id);
|
||||
if (response.success) {
|
||||
setPurchaseModalOpen(false);
|
||||
setInstructionModalOpen(true);
|
||||
|
||||
// Refresh user data to update balance
|
||||
try {
|
||||
const userResponse = await apiService.getCurrentUser();
|
||||
if (userResponse.success && userResponse.data) {
|
||||
updateUser({ stars_balance: userResponse.data.stars_balance });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error refreshing user data:', err);
|
||||
}
|
||||
} else {
|
||||
setError(response.message || 'Не удалось приобрести приз');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error purchasing reward:', err);
|
||||
setError('Произошла ошибка при покупке');
|
||||
} finally {
|
||||
setPurchasing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canAfford = (price: number) => {
|
||||
return user && user.stars_balance >= price;
|
||||
};
|
||||
|
||||
const isInStock = (stock: number) => {
|
||||
return stock === 0 || stock > 0;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
|
||||
<CircularProgress sx={{ color: '#FFD700' }} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{
|
||||
mt: 4,
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
color: '#ffffff',
|
||||
border: '1px solid #f44336',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Header */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
color: '#FFD700',
|
||||
fontWeight: 'bold',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
Магазин призов 🛍️
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#ffffff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
Ваш баланс: {user?.stars_balance} ⭐
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Rewards Grid */}
|
||||
<Grid container component="div" spacing={3}>
|
||||
{rewards.map((reward) => (
|
||||
<GridItem xs={12} sm={6} component="div" key={reward.id}>
|
||||
<Card
|
||||
sx={{
|
||||
backgroundColor: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
transition: 'transform 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
border: '1px solid #FFD700',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
height="200"
|
||||
image={reward.image_url || '/placeholder-reward.jpg'}
|
||||
alt={reward.title}
|
||||
sx={{ objectFit: 'cover' }}
|
||||
/>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
{reward.title}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#888',
|
||||
mb: 2,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{reward.description}
|
||||
</Typography>
|
||||
|
||||
{/* Price and Status */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||
<Chip
|
||||
label={`${reward.price_stars} ⭐`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: canAfford(reward.price_stars)
|
||||
? 'rgba(255, 215, 0, 0.2)'
|
||||
: 'rgba(244, 67, 54, 0.2)',
|
||||
color: canAfford(reward.price_stars) ? '#FFD700' : '#f44336',
|
||||
border: canAfford(reward.price_stars)
|
||||
? '1px solid #FFD700'
|
||||
: '1px solid #f44336',
|
||||
}}
|
||||
/>
|
||||
<Chip
|
||||
icon={reward.delivery_type === 'physical' ? <LocalShipping /> : <Code />}
|
||||
label={reward.delivery_type === 'physical' ? 'Физический' : 'Цифровой'}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
/>
|
||||
{!isInStock(reward.stock) && (
|
||||
<Chip
|
||||
icon={<Inventory />}
|
||||
label="Нет в наличии"
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.2)',
|
||||
color: '#f44336',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Action Button */}
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={() => handlePurchase(reward)}
|
||||
disabled={!canAfford(reward.price_stars) || !isInStock(reward.stock)}
|
||||
sx={{
|
||||
backgroundColor: canAfford(reward.price_stars) && isInStock(reward.stock)
|
||||
? '#FFD700'
|
||||
: '#666',
|
||||
color: canAfford(reward.price_stars) && isInStock(reward.stock)
|
||||
? '#000000'
|
||||
: '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
'&:hover': {
|
||||
backgroundColor: canAfford(reward.price_stars) && isInStock(reward.stock)
|
||||
? '#FFC700'
|
||||
: '#666',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{!canAfford(reward.price_stars)
|
||||
? 'Недостаточно ⭐'
|
||||
: !isInStock(reward.stock)
|
||||
? 'Нет в наличии'
|
||||
: 'Купить'
|
||||
}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</GridItem>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{rewards.length === 0 && (
|
||||
<Box sx={{ textAlign: 'center', mt: 8 }}>
|
||||
<ShoppingBag sx={{ fontSize: 64, color: '#666', mb: 2 }} />
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{ color: '#888', mb: 2 }}
|
||||
>
|
||||
Пока нет доступных призов
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: '#666' }}
|
||||
>
|
||||
Загляните позже - призы появятся скоро!
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Purchase Confirmation Modal */}
|
||||
<Modal
|
||||
open={purchaseModalOpen}
|
||||
onClose={() => setPurchaseModalOpen(false)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: 2,
|
||||
p: 4,
|
||||
maxWidth: 400,
|
||||
width: '90%',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#ffffff',
|
||||
fontWeight: 'bold',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
Подтверждение покупки
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: '#888', mb: 1 }}
|
||||
>
|
||||
Вы уверены, что хотите купить "{selectedReward?.title}" за {selectedReward?.price_stars} ⭐?
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 3 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
onClick={() => setPurchaseModalOpen(false)}
|
||||
sx={{
|
||||
borderColor: '#333',
|
||||
color: '#ffffff',
|
||||
}}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={confirmPurchase}
|
||||
disabled={purchasing}
|
||||
sx={{
|
||||
backgroundColor: '#FFD700',
|
||||
color: '#000000',
|
||||
}}
|
||||
>
|
||||
{purchasing ? <CircularProgress size={20} /> : 'Купить'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
||||
{/* Instructions Modal */}
|
||||
<Modal
|
||||
open={instructionModalOpen}
|
||||
onClose={() => setInstructionModalOpen(false)}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: '#1a1a1a',
|
||||
border: '1px solid #333',
|
||||
borderRadius: 2,
|
||||
p: 4,
|
||||
maxWidth: 400,
|
||||
width: '90%',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: '#4CAF50',
|
||||
fontWeight: 'bold',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
Покупка успешно оформлена! 🎉
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{ color: '#ffffff', mb: 2 }}
|
||||
>
|
||||
{selectedReward?.instructions}
|
||||
</Typography>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
onClick={() => setInstructionModalOpen(false)}
|
||||
sx={{
|
||||
backgroundColor: '#4CAF50',
|
||||
color: '#ffffff',
|
||||
mt: 2,
|
||||
}}
|
||||
>
|
||||
Понятно
|
||||
</Button>
|
||||
</Box>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
216
frontend/src/services/api.ts
Normal file
216
frontend/src/services/api.ts
Normal file
@ -0,0 +1,216 @@
|
||||
import axios from 'axios';
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import type { ApiResponse } from '../types';
|
||||
|
||||
class ApiService {
|
||||
private api: AxiosInstance;
|
||||
private baseURL: string = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api';
|
||||
|
||||
constructor() {
|
||||
this.api = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor to add auth token
|
||||
this.api.interceptors.request.use(
|
||||
(config) => {
|
||||
// For Telegram Web App, use X-Telegram-WebApp-Init-Data header
|
||||
const telegramInitData = localStorage.getItem('telegram_init_data');
|
||||
if (telegramInitData) {
|
||||
config.headers['X-Telegram-WebApp-Init-Data'] = telegramInitData;
|
||||
} else {
|
||||
// Fallback to Bearer token for non-Telegram auth
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor to handle errors
|
||||
this.api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Auth methods
|
||||
async validateTelegramInitData(initData: string): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.post('/auth/validate', { initData });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getCurrentUser(): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.get('/auth/me');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Quiz methods
|
||||
async getAllQuizzes(): Promise<ApiResponse<any[]>> {
|
||||
const response = await this.api.get('/quizzes');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getQuizById(id: number): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.get(`/quizzes/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async submitQuiz(id: number, submissionData: any): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.post(`/quizzes/${id}/submit`, submissionData);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async canRepeatQuiz(id: number): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.get(`/quizzes/${id}/can-repeat`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Reward methods
|
||||
async getAllRewards(): Promise<ApiResponse<any[]>> {
|
||||
const response = await this.api.get('/rewards');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async purchaseReward(id: number): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.post(`/rewards/${id}/purchase`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// User methods
|
||||
async getUserProfile(): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.get('/me');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getUserTransactions(): Promise<ApiResponse<any[]>> {
|
||||
const response = await this.api.get('/user/transactions');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getUserPurchases(): Promise<ApiResponse<any[]>> {
|
||||
const response = await this.api.get('/user/purchases');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// QR methods
|
||||
async validateQR(payload: string): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.post('/qr/validate', { payload });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Admin methods
|
||||
async createQuiz(quiz: any): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.post('/admin/quizzes', quiz);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateQuiz(id: number, quiz: any): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.put(`/admin/quizzes/${id}`, quiz);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteQuiz(id: number): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.delete(`/admin/quizzes/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async createQuestion(quizId: number, question: any): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.post(`/admin/quizzes/${quizId}/questions`, question);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateQuestion(quizId: number, questionId: number, question: any): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.put(`/admin/quizzes/${quizId}/questions/${questionId}`, question);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteQuestion(quizId: number, questionId: number): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.delete(`/admin/quizzes/${quizId}/questions/${questionId}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async createReward(reward: any): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.post('/admin/rewards', reward);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async updateReward(id: number, reward: any): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.put(`/admin/rewards/${id}`, reward);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteReward(id: number): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.delete(`/admin/rewards/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async grantStars(data: any): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.post('/admin/users/grant-stars', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async createOperator(data: any): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.post('/admin/operators', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async deleteOperator(id: number): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.delete(`/admin/operators/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getAnalytics(): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.get('/admin/analytics');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async generateQRCodes(data: any): Promise<ApiResponse<any>> {
|
||||
const response = await this.api.post('/admin/qrcodes', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Utility method to set auth token
|
||||
setAuthToken(token: string) {
|
||||
localStorage.setItem('auth_token', token);
|
||||
}
|
||||
|
||||
// Utility method to set Telegram init data
|
||||
setTelegramInitData(initData: string) {
|
||||
localStorage.setItem('telegram_init_data', initData);
|
||||
}
|
||||
|
||||
// Utility method to clear auth token
|
||||
clearAuthToken() {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('telegram_init_data');
|
||||
}
|
||||
|
||||
// Utility method to check if user is authenticated
|
||||
isAuthenticated(): boolean {
|
||||
return !!(localStorage.getItem('auth_token') || localStorage.getItem('telegram_init_data'));
|
||||
}
|
||||
|
||||
// Check if user has admin privileges
|
||||
async checkAdminRole(): Promise<ApiResponse<{ role: string }>> {
|
||||
const response = await this.api.get('/auth/admin-role');
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const apiService = new ApiService();
|
||||
export default apiService;
|
||||
363
frontend/src/styles/animations.css
Normal file
363
frontend/src/styles/animations.css
Normal file
@ -0,0 +1,363 @@
|
||||
/* Global animations for the app */
|
||||
|
||||
/* Star animations */
|
||||
@keyframes starPulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes starRotate {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes starBounce {
|
||||
0%, 20%, 50%, 80%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
60% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Floating animation */
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Pulse animation */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 6px 20px rgba(255, 215, 0, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 8px 30px rgba(255, 215, 0, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
/* Glow effect */
|
||||
@keyframes glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 5px rgba(255, 215, 0, 0.5);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(255, 215, 0, 0.8), 0 0 30px rgba(255, 215, 0, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
/* Shine effect */
|
||||
@keyframes shine {
|
||||
0% {
|
||||
background-position: -200% center;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Slide in animations */
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fade animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Bounce animations */
|
||||
@keyframes bounce {
|
||||
0%, 20%, 53%, 80%, 100% {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
40%, 43% {
|
||||
transform: translate3d(0, -15px, 0);
|
||||
}
|
||||
70% {
|
||||
transform: translate3d(0, -7px, 0);
|
||||
}
|
||||
90% {
|
||||
transform: translate3d(0, -3px, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Shake animation */
|
||||
@keyframes shake {
|
||||
0%, 100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
10%, 30%, 50%, 70%, 90% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
20%, 40%, 60%, 80% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Heartbeat animation */
|
||||
@keyframes heartbeat {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
14% {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
28% {
|
||||
transform: scale(1);
|
||||
}
|
||||
42% {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Ripple effect */
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Staggered animations for lists */
|
||||
@keyframes staggerIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.animate-star-pulse {
|
||||
animation: starPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-star-rotate {
|
||||
animation: starRotate 3s linear infinite;
|
||||
}
|
||||
|
||||
.animate-star-bounce {
|
||||
animation: starBounce 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-glow {
|
||||
animation: glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-shine {
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
background-size: 200% 100%;
|
||||
animation: shine 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-slide-in-up {
|
||||
animation: slideInUp 0.5s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in-down {
|
||||
animation: slideInDown 0.5s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in-left {
|
||||
animation: slideInLeft 0.5s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slideInRight 0.5s ease-out;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
.animate-fade-in-scale {
|
||||
animation: fadeInScale 0.5s ease-out;
|
||||
}
|
||||
|
||||
.animate-bounce {
|
||||
animation: bounce 1s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-shake {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.animate-heartbeat {
|
||||
animation: heartbeat 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Staggered animation for children */
|
||||
.stagger-children > * {
|
||||
animation: staggerIn 0.4s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.stagger-children > *:nth-child(1) { animation-delay: 0.1s; }
|
||||
.stagger-children > *:nth-child(2) { animation-delay: 0.2s; }
|
||||
.stagger-children > *:nth-child(3) { animation-delay: 0.3s; }
|
||||
.stagger-children > *:nth-child(4) { animation-delay: 0.4s; }
|
||||
.stagger-children > *:nth-child(5) { animation-delay: 0.5s; }
|
||||
.stagger-children > *:nth-child(6) { animation-delay: 0.6s; }
|
||||
.stagger-children > *:nth-child(7) { animation-delay: 0.7s; }
|
||||
.stagger-children > *:nth-child(8) { animation-delay: 0.8s; }
|
||||
|
||||
/* Hover effects */
|
||||
.hover-scale {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.hover-scale:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.hover-lift {
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.hover-glow {
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.hover-glow:hover {
|
||||
box-shadow: 0 0 20px rgba(255, 215, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.loading-skeleton {
|
||||
background: linear-gradient(90deg, #1a1a1a 25%, #2a2a2a 50%, #1a1a1a 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shine 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Responsive animations */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-star-pulse,
|
||||
.animate-star-rotate,
|
||||
.animate-star-bounce,
|
||||
.animate-float,
|
||||
.animate-pulse,
|
||||
.animate-glow,
|
||||
.animate-shine,
|
||||
.animate-slide-in-up,
|
||||
.animate-slide-in-down,
|
||||
.animate-slide-in-left,
|
||||
.animate-slide-in-right,
|
||||
.animate-fade-in,
|
||||
.animate-fade-in-scale,
|
||||
.animate-bounce,
|
||||
.animate-shake,
|
||||
.animate-spin,
|
||||
.animate-heartbeat {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
344
frontend/src/theme/index.ts
Normal file
344
frontend/src/theme/index.ts
Normal file
@ -0,0 +1,344 @@
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
export const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'dark',
|
||||
primary: {
|
||||
main: '#FFD700', // Gold stars
|
||||
light: '#FFED4E',
|
||||
dark: '#D4AF37',
|
||||
},
|
||||
secondary: {
|
||||
main: '#4CAF50', // Success green
|
||||
light: '#66BB6A',
|
||||
dark: '#388E3C',
|
||||
},
|
||||
background: {
|
||||
default: '#0F0F0F', // Dark background
|
||||
paper: '#1A1A1A', // Card background
|
||||
},
|
||||
text: {
|
||||
primary: '#FFFFFF',
|
||||
secondary: '#B0B0B0',
|
||||
disabled: '#666666',
|
||||
},
|
||||
success: {
|
||||
main: '#4CAF50',
|
||||
light: '#81C784',
|
||||
},
|
||||
error: {
|
||||
main: '#F44336',
|
||||
light: '#EF5350',
|
||||
},
|
||||
warning: {
|
||||
main: '#FF9800',
|
||||
light: '#FFB74D',
|
||||
},
|
||||
info: {
|
||||
main: '#2196F3',
|
||||
light: '#42A5F5',
|
||||
},
|
||||
divider: '#2A2A2A',
|
||||
action: {
|
||||
hover: 'rgba(255, 215, 0, 0.1)',
|
||||
selected: 'rgba(255, 215, 0, 0.2)',
|
||||
disabled: 'rgba(255, 255, 255, 0.1)',
|
||||
disabledBackground: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||
fontSize: 14,
|
||||
h1: {
|
||||
fontSize: 28,
|
||||
fontWeight: 600,
|
||||
},
|
||||
h2: {
|
||||
fontSize: 24,
|
||||
fontWeight: 600,
|
||||
},
|
||||
h3: {
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
},
|
||||
h4: {
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
},
|
||||
h5: {
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
},
|
||||
h6: {
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
},
|
||||
body1: {
|
||||
fontSize: 16,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
body2: {
|
||||
fontSize: 14,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
subtitle1: {
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
},
|
||||
subtitle2: {
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
shadows: [
|
||||
'none',
|
||||
'0px 1px 3px rgba(0,0,0,0.12), 0px 1px 2px rgba(0,0,0,0.24)',
|
||||
'0px 3px 6px rgba(0,0,0,0.16), 0px 3px 6px rgba(0,0,0,0.23)',
|
||||
'0px 10px 20px rgba(0,0,0,0.19), 0px 6px 6px rgba(0,0,0,0.23)',
|
||||
'0px 14px 28px rgba(0,0,0,0.25), 0px 10px 10px rgba(0,0,0,0.22)',
|
||||
'0px 19px 38px rgba(0,0,0,0.3), 0px 15px 12px rgba(0,0,0,0.22)',
|
||||
'none', 'none', 'none', 'none', 'none', 'none', 'none', 'none', 'none', 'none',
|
||||
'none', 'none', 'none', 'none', 'none', 'none', 'none', 'none', 'none',
|
||||
],
|
||||
components: {
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
borderRadius: 8,
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: 'none',
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 12px rgba(255, 215, 0, 0.3)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(1px)',
|
||||
},
|
||||
},
|
||||
contained: {
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
},
|
||||
containedPrimary: {
|
||||
color: '#000000',
|
||||
backgroundColor: '#FFD700',
|
||||
'&:hover': {
|
||||
backgroundColor: '#FFC700',
|
||||
},
|
||||
},
|
||||
containedSecondary: {
|
||||
backgroundColor: '#4CAF50',
|
||||
'&:hover': {
|
||||
backgroundColor: '#66BB6A',
|
||||
},
|
||||
},
|
||||
outlined: {
|
||||
borderWidth: 2,
|
||||
'&:hover': {
|
||||
borderWidth: 2,
|
||||
},
|
||||
},
|
||||
outlinedPrimary: {
|
||||
borderColor: '#FFD700',
|
||||
color: '#FFD700',
|
||||
},
|
||||
sizeLarge: {
|
||||
padding: '12px 24px',
|
||||
fontSize: 16,
|
||||
borderRadius: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4)',
|
||||
border: '1px solid rgba(255, 215, 0, 0.3)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCardContent: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
padding: 16,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
fontWeight: 600,
|
||||
fontSize: 12,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
},
|
||||
colorPrimary: {
|
||||
backgroundColor: 'rgba(255, 215, 0, 0.15)',
|
||||
color: '#FFD700',
|
||||
borderColor: '#FFD700',
|
||||
},
|
||||
colorSecondary: {
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.15)',
|
||||
color: '#4CAF50',
|
||||
},
|
||||
colorSuccess: {
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.15)',
|
||||
color: '#4CAF50',
|
||||
},
|
||||
colorWarning: {
|
||||
backgroundColor: 'rgba(255, 152, 0, 0.15)',
|
||||
color: '#FF9800',
|
||||
},
|
||||
outlinedPrimary: {
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #FFD700',
|
||||
color: '#FFD700',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiContainer: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
'@media (min-width: 600px)': {
|
||||
paddingLeft: 24,
|
||||
paddingRight: 24,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTypography: {
|
||||
styleOverrides: {
|
||||
h1: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
h2: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
h3: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
h4: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
h5: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
h6: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
subtitle1: {
|
||||
color: '#B0B0B0',
|
||||
},
|
||||
subtitle2: {
|
||||
color: '#888888',
|
||||
},
|
||||
body1: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
body2: {
|
||||
color: '#B0B0B0',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAlert: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12,
|
||||
fontSize: 14,
|
||||
},
|
||||
standardError: {
|
||||
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
||||
color: '#FFFFFF',
|
||||
border: '1px solid #F44336',
|
||||
},
|
||||
standardSuccess: {
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.1)',
|
||||
color: '#FFFFFF',
|
||||
border: '1px solid #4CAF50',
|
||||
},
|
||||
standardWarning: {
|
||||
backgroundColor: 'rgba(255, 152, 0, 0.1)',
|
||||
color: '#FFFFFF',
|
||||
border: '1px solid #FF9800',
|
||||
},
|
||||
standardInfo: {
|
||||
backgroundColor: 'rgba(33, 150, 243, 0.1)',
|
||||
color: '#FFFFFF',
|
||||
border: '1px solid #2196F3',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundImage: 'linear-gradient(rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.05))',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiLinearProgress: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
bar: {
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#FFD700',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCircularProgress: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
color: '#FFD700',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiIconButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(255, 215, 0, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundImage: 'linear-gradient(to bottom, rgba(26, 26, 26, 0.98), rgba(15, 15, 15, 0.98))',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
breakpoints: {
|
||||
values: {
|
||||
xs: 0,
|
||||
sm: 600,
|
||||
md: 900,
|
||||
lg: 1200,
|
||||
xl: 1536,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default theme;
|
||||
159
frontend/src/types/index.ts
Normal file
159
frontend/src/types/index.ts
Normal file
@ -0,0 +1,159 @@
|
||||
// API Response types
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
// User types
|
||||
export interface User {
|
||||
telegram_id: number;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
stars_balance: number;
|
||||
created_at: string;
|
||||
photo_url?: string;
|
||||
}
|
||||
|
||||
// Quiz types
|
||||
export interface Quiz {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
image_url: string;
|
||||
reward_stars: number;
|
||||
has_timer: boolean;
|
||||
timer_per_question: number;
|
||||
can_repeat: boolean;
|
||||
repeat_cooldown_hours: number;
|
||||
is_active: boolean;
|
||||
created_by: number;
|
||||
created_at: string;
|
||||
questions?: Question[];
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
id: number;
|
||||
quiz_id: number;
|
||||
text: string;
|
||||
type: 'single' | 'multiple';
|
||||
options: Option[];
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
export interface Option {
|
||||
id: number;
|
||||
text: string;
|
||||
is_correct: boolean;
|
||||
}
|
||||
|
||||
export interface UserAnswer {
|
||||
question_id: number;
|
||||
option_ids: number[];
|
||||
}
|
||||
|
||||
export interface SubmissionRequest {
|
||||
answers: UserAnswer[];
|
||||
}
|
||||
|
||||
export interface SubmissionResponse {
|
||||
score: number;
|
||||
total_questions: number;
|
||||
stars_earned: number;
|
||||
correct_answers: number;
|
||||
}
|
||||
|
||||
export interface CanRepeatResponse {
|
||||
can_repeat: boolean;
|
||||
next_available_at?: string;
|
||||
}
|
||||
|
||||
// Reward types
|
||||
export interface Reward {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
image_url?: string;
|
||||
price_stars: number;
|
||||
delivery_type: 'physical' | 'digital';
|
||||
instructions?: string;
|
||||
stock: number;
|
||||
is_active: boolean;
|
||||
created_by?: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Purchase {
|
||||
id: number;
|
||||
user_id: number;
|
||||
reward_id: number;
|
||||
stars_spent: number;
|
||||
purchased_at: string;
|
||||
status: 'pending' | 'delivered' | 'cancelled';
|
||||
}
|
||||
|
||||
// Transaction types
|
||||
export interface Transaction {
|
||||
amount: number;
|
||||
created_at: string;
|
||||
description: string;
|
||||
type: 'earned' | 'spent';
|
||||
}
|
||||
|
||||
// QR types
|
||||
export interface QRValidateRequest {
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface QRValidateResponse {
|
||||
type: 'reward' | 'quiz' | 'shop';
|
||||
value: string;
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
// Admin types
|
||||
export interface CreateOperatorRequest {
|
||||
telegram_id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface GrantStarsRequest {
|
||||
user_id: number;
|
||||
amount: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface GenerateQRCodesRequest {
|
||||
type: 'reward' | 'quiz';
|
||||
value: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface GenerateQRCodesResponse {
|
||||
tokens: string[];
|
||||
}
|
||||
|
||||
export interface Analytics {
|
||||
total_users: number;
|
||||
total_quizzes_completed: number;
|
||||
total_stars_earned: number;
|
||||
total_purchases: number;
|
||||
recent_activity: {
|
||||
date: string;
|
||||
quizzes_completed: number;
|
||||
qr_scans: number;
|
||||
purchases: number;
|
||||
}[];
|
||||
top_quizzes: {
|
||||
id: number;
|
||||
title: string;
|
||||
completions: number;
|
||||
}[];
|
||||
top_rewards: {
|
||||
id: number;
|
||||
title: string;
|
||||
purchases: number;
|
||||
}[];
|
||||
}
|
||||
61
frontend/src/types/telegram.ts
Normal file
61
frontend/src/types/telegram.ts
Normal file
@ -0,0 +1,61 @@
|
||||
// Re-export TelegramWebApp from vite-env.d.ts to maintain compatibility
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface TelegramWebApp {
|
||||
initData?: string;
|
||||
initDataUnsafe?: {
|
||||
start_param?: string;
|
||||
user?: {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
language_code?: string;
|
||||
photo_url?: string;
|
||||
};
|
||||
};
|
||||
ready: () => void;
|
||||
expand: () => void;
|
||||
close: () => void;
|
||||
enableClosingConfirmation?: () => void;
|
||||
setBackgroundColor?: (color: string) => void;
|
||||
setHeaderColor?: (color: string) => void;
|
||||
onEvent?: (event: string, callback: () => void) => void;
|
||||
offEvent?: (event: string, callback: () => void) => void;
|
||||
HapticFeedback?: {
|
||||
impactOccurred: (style: 'light' | 'medium' | 'heavy' | 'rigid' | 'soft') => void;
|
||||
notificationOccurred: (type: 'error' | 'success' | 'warning') => void;
|
||||
selectionChanged: () => void;
|
||||
};
|
||||
MainButton?: {
|
||||
text: string;
|
||||
color: string;
|
||||
textColor: string;
|
||||
isVisible: boolean;
|
||||
isActive: boolean;
|
||||
show: () => void;
|
||||
hide: () => void;
|
||||
enable: () => void;
|
||||
disable: () => void;
|
||||
setText: (text: string) => void;
|
||||
onClick: (callback: () => void) => void;
|
||||
offClick: (callback: () => void) => void;
|
||||
};
|
||||
BackButton?: {
|
||||
isVisible: boolean;
|
||||
show: () => void;
|
||||
hide: () => void;
|
||||
onClick: (callback: () => void) => void;
|
||||
offClick: (callback: () => void) => void;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Telegram?: {
|
||||
WebApp: TelegramWebApp;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type { TelegramWebApp };
|
||||
152
frontend/src/utils/telegram.ts
Normal file
152
frontend/src/utils/telegram.ts
Normal file
@ -0,0 +1,152 @@
|
||||
|
||||
export const telegramUtils = {
|
||||
// Haptic feedback functions
|
||||
haptic: {
|
||||
impact: (style: 'light' | 'medium' | 'heavy' | 'rigid' | 'soft' = 'medium') => {
|
||||
if (window.Telegram?.WebApp?.HapticFeedback) {
|
||||
try {
|
||||
window.Telegram.WebApp.HapticFeedback.impactOccurred(style);
|
||||
} catch (error) {
|
||||
console.warn('Haptic feedback not supported:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
notification: (type: 'error' | 'success' | 'warning') => {
|
||||
if (window.Telegram?.WebApp?.HapticFeedback) {
|
||||
try {
|
||||
window.Telegram.WebApp.HapticFeedback.notificationOccurred(type);
|
||||
} catch (error) {
|
||||
console.warn('Haptic feedback not supported:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
selection: () => {
|
||||
if (window.Telegram?.WebApp?.HapticFeedback) {
|
||||
try {
|
||||
window.Telegram.WebApp.HapticFeedback.selectionChanged();
|
||||
} catch (error) {
|
||||
console.warn('Haptic feedback not supported:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Main button functions
|
||||
mainButton: {
|
||||
show: (text: string, onClick: () => void) => {
|
||||
if (window.Telegram?.WebApp?.MainButton) {
|
||||
try {
|
||||
window.Telegram.WebApp.MainButton.setText(text);
|
||||
window.Telegram.WebApp.MainButton.show();
|
||||
window.Telegram.WebApp.MainButton.onClick(onClick);
|
||||
window.Telegram.WebApp.MainButton.enable();
|
||||
} catch (error) {
|
||||
console.warn('Main button not supported:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
hide: (onClick: () => void) => {
|
||||
if (window.Telegram?.WebApp?.MainButton) {
|
||||
try {
|
||||
window.Telegram.WebApp.MainButton.offClick(onClick);
|
||||
window.Telegram.WebApp.MainButton.hide();
|
||||
} catch (error) {
|
||||
console.warn('Main button not supported:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
enable: () => {
|
||||
if (window.Telegram?.WebApp?.MainButton) {
|
||||
try {
|
||||
window.Telegram.WebApp.MainButton.enable();
|
||||
} catch (error) {
|
||||
console.warn('Main button not supported:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
disable: () => {
|
||||
if (window.Telegram?.WebApp?.MainButton) {
|
||||
try {
|
||||
window.Telegram.WebApp.MainButton.disable();
|
||||
} catch (error) {
|
||||
console.warn('Main button not supported:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Back button functions
|
||||
backButton: {
|
||||
show: (onClick: () => void) => {
|
||||
if (window.Telegram?.WebApp?.BackButton) {
|
||||
try {
|
||||
window.Telegram.WebApp.BackButton.show();
|
||||
window.Telegram.WebApp.BackButton.onClick(onClick);
|
||||
} catch (error) {
|
||||
console.warn('Back button not supported:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
hide: (onClick: () => void) => {
|
||||
if (window.Telegram?.WebApp?.BackButton) {
|
||||
try {
|
||||
window.Telegram.WebApp.BackButton.offClick(onClick);
|
||||
window.Telegram.WebApp.BackButton.hide();
|
||||
} catch (error) {
|
||||
console.warn('Back button not supported:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Theme functions
|
||||
theme: {
|
||||
setColors: (bgColor: string = '#0a0a0a', headerColor: string = '#1a1a1a') => {
|
||||
if (window.Telegram?.WebApp) {
|
||||
try {
|
||||
window.Telegram.WebApp.setBackgroundColor?.(bgColor);
|
||||
window.Telegram.WebApp.setHeaderColor?.(headerColor);
|
||||
} catch (error) {
|
||||
console.warn('Theme setting not supported:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Utility functions
|
||||
utils: {
|
||||
isTelegramWebApp: (): boolean => {
|
||||
return !!window.Telegram?.WebApp;
|
||||
},
|
||||
|
||||
getInitData: (): string | null => {
|
||||
return window.Telegram?.WebApp?.initData || null;
|
||||
},
|
||||
|
||||
expand: () => {
|
||||
if (window.Telegram?.WebApp) {
|
||||
try {
|
||||
window.Telegram.WebApp.expand();
|
||||
} catch (error) {
|
||||
console.warn('Expand not supported:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
close: () => {
|
||||
if (window.Telegram?.WebApp) {
|
||||
try {
|
||||
window.Telegram.WebApp.close();
|
||||
} catch (error) {
|
||||
console.warn('Close not supported:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
27
frontend/tsconfig.app.json
Normal file
27
frontend/tsconfig.app.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user