Init commit

This commit is contained in:
NikitolProject 2025-09-17 22:22:14 +03:00
commit c9fde121be
104 changed files with 23562 additions and 0 deletions

256
.gitignore vendored Normal file
View 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
View 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
View 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

View 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
View 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
View 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
View 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 (магазин)
- **Валидация** проверяет существование, срок действия и факт использования

View 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)
}
}

View 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

File diff suppressed because it is too large Load Diff

2076
backend/docs/swagger.json Normal file

File diff suppressed because it is too large Load Diff

1327
backend/docs/swagger.yaml Normal file

File diff suppressed because it is too large Load Diff

61
backend/go.mod Normal file
View 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
View 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=

View 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
}

View 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
}

View 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,
})
}

View 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),
},
})
}

View 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.

View 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,
},
})
}

View 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",
})
}

View 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",
})
}

View 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",
})
}

View 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,
})
}

View 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)
}

View 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)
}

View 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"`
}

View 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
}

View 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
}
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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)
}

View 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
}

View 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
}

View 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
}

View 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)
}

View 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
}

View 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)
}
}

View 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)
}

View 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
}

View 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
}

View 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
}

View File

@ -0,0 +1,10 @@
package types
// UserRole represents user roles
type UserRole string
const (
RoleAdmin UserRole = "admin"
RoleOperator UserRole = "operator"
RoleUser UserRole = "user"
)

View 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";

View 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

Binary file not shown.

23
bot/.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
aiogram==3.15.0
python-dotenv==1.0.1
aiohttp==3.10.10

148
bot/utils.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

1
frontend/public/vite.svg Normal file
View 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
View 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
View 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

View 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

View 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>
</>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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
View 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
View 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>,
)

File diff suppressed because it is too large Load Diff

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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;

View 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
View 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
View 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;
}[];
}

View 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 };

View 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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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
View 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