commit c9fde121be12bc88c08dbf978c530f3abaf02b70 Author: NikitolProject Date: Wed Sep 17 22:22:14 2025 +0300 Init commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31e034a --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..dd57a72 --- /dev/null +++ b/DEVELOPMENT.md @@ -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 доступен +- Проверьте формат токенов \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..094ff30 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/1753683755292-30b3431f487b4cc1863e57a81d78e289.sh b/backend/1753683755292-30b3431f487b4cc1863e57a81d78e289.sh new file mode 100755 index 0000000..d4cb8cb --- /dev/null +++ b/backend/1753683755292-30b3431f487b4cc1863e57a81d78e289.sh @@ -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 "$@" diff --git a/backend/AUTH_GUIDE.md b/backend/AUTH_GUIDE.md new file mode 100644 index 0000000..7997d69 --- /dev/null +++ b/backend/AUTH_GUIDE.md @@ -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") +// } +``` \ No newline at end of file diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000..cecdfc0 --- /dev/null +++ b/backend/Makefile @@ -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!" \ No newline at end of file diff --git a/backend/QR_API_EXAMPLES.md b/backend/QR_API_EXAMPLES.md new file mode 100644 index 0000000..e6909d4 --- /dev/null +++ b/backend/QR_API_EXAMPLES.md @@ -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 (магазин) +- **Валидация** проверяет существование, срок действия и факт использования \ No newline at end of file diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..e94cdd3 --- /dev/null +++ b/backend/cmd/server/main.go @@ -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) + } +} diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..13e6b66 --- /dev/null +++ b/backend/docker-compose.yml @@ -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: diff --git a/backend/docs/docs.go b/backend/docs/docs.go new file mode 100644 index 0000000..f94faec --- /dev/null +++ b/backend/docs/docs.go @@ -0,0 +1,2100 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/api/admin/analytics": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns analytics data including user statistics, rewards, and quiz performance (admin/operator only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Get analytics data", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/admin/operators": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Creates a new operator account (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Create operator", + "parameters": [ + { + "description": "Create operator request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.CreateOperatorRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/admin/operators/{id}": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes an operator account (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Delete operator", + "parameters": [ + { + "type": "integer", + "description": "Operator Telegram ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/admin/qrcodes": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Generates unique QR codes for rewards, quizzes, or shop items (admin/operator only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "qr" + ], + "summary": "Generate QR codes", + "parameters": [ + { + "description": "QR codes generation request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.GenerateQRCodesRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.GenerateQRCodesResponse" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/admin/quizzes": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Creates a new quiz (admin/operator only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "quizzes" + ], + "summary": "Create a new quiz", + "parameters": [ + { + "description": "Quiz object", + "name": "quiz", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Quiz" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.Quiz" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/admin/quizzes/{id}": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates an existing quiz (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "quizzes" + ], + "summary": "Update a quiz", + "parameters": [ + { + "type": "integer", + "description": "Quiz ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Updated quiz object", + "name": "quiz", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Quiz" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.Quiz" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes a quiz (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "quizzes" + ], + "summary": "Delete a quiz", + "parameters": [ + { + "type": "integer", + "description": "Quiz ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/admin/quizzes/{quiz_id}/questions": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Adds a new question to a quiz (admin/operator only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "Create question", + "parameters": [ + { + "type": "integer", + "description": "Quiz ID", + "name": "quiz_id", + "in": "path", + "required": true + }, + { + "description": "Question object", + "name": "question", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Question" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.Question" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/admin/rewards": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Creates a new reward that users can purchase with stars (admin/operator only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rewards" + ], + "summary": "Create a new reward", + "parameters": [ + { + "description": "Reward object", + "name": "reward", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Reward" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.Reward" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/admin/rewards/{id}": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates an existing reward (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rewards" + ], + "summary": "Update a reward", + "parameters": [ + { + "type": "integer", + "description": "Reward ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Updated reward object", + "name": "reward", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Reward" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.Reward" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes a reward (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rewards" + ], + "summary": "Delete a reward", + "parameters": [ + { + "type": "integer", + "description": "Reward ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/admin/users/grant-stars": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Manually grants stars to a user (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Grant stars to user", + "parameters": [ + { + "description": "Grant stars request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.GrantStarsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/auth/me": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns information about the currently authenticated Telegram user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Get current authenticated user", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/auth/validate": { + "post": { + "description": "Validates Telegram WebApp init data and returns user information", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Validate Telegram WebApp init data", + "parameters": [ + { + "description": "Init data validation request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "initData": { + "type": "string" + } + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/me": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns the current user's profile information including balance", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get current user profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.User" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/qr/validate": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Validates a QR code payload and processes the associated action", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "qr" + ], + "summary": "Validate QR code payload", + "parameters": [ + { + "description": "QR validation request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.QRValidateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/quizzes": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of all active quizzes", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "quizzes" + ], + "summary": "Get all active quizzes", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Quiz" + } + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/quizzes/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a single quiz with all its questions", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "quizzes" + ], + "summary": "Get quiz by ID", + "parameters": [ + { + "type": "integer", + "description": "Quiz ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.Quiz" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/quizzes/{id}/can-repeat": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Checks if user can repeat a quiz and when it will be available", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "quizzes" + ], + "summary": "Check if quiz can be repeated", + "parameters": [ + { + "type": "integer", + "description": "Quiz ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.CanRepeatResponse" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/quizzes/{id}/submit": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Submits quiz answers and calculates score/stars earned", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "quizzes" + ], + "summary": "Submit quiz answers", + "parameters": [ + { + "type": "integer", + "description": "Quiz ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Quiz submission", + "name": "submission", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.SubmissionRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/rewards": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of all active rewards available for purchase", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rewards" + ], + "summary": "Get all active rewards", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Reward" + } + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/rewards/{id}/purchase": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Allows a user to purchase a reward using their stars", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rewards" + ], + "summary": "Purchase a reward", + "parameters": [ + { + "type": "integer", + "description": "Reward ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.Purchase" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/user/purchases": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns the current user's purchase history", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get user purchase history", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Purchase" + } + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/user/transactions": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns the current user's transaction history (earned/spent stars)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get user transaction history", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Transaction" + } + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + } + }, + "definitions": { + "models.CanRepeatResponse": { + "type": "object", + "properties": { + "can_repeat": { + "type": "boolean" + }, + "next_available_at": { + "type": "string" + } + } + }, + "models.CreateOperatorRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "telegram_id": { + "type": "integer" + } + } + }, + "models.DeliveryType": { + "type": "string", + "enum": [ + "physical", + "digital" + ], + "x-enum-varnames": [ + "Physical", + "Digital" + ] + }, + "models.GenerateQRCodesRequest": { + "type": "object", + "properties": { + "count": { + "description": "Number of codes to generate", + "type": "integer" + }, + "type": { + "description": "e.g., \"reward\", \"quiz\"", + "type": "string" + }, + "value": { + "description": "e.g., \"50\" for reward, \"1\" for quiz", + "type": "string" + } + } + }, + "models.GenerateQRCodesResponse": { + "type": "object", + "properties": { + "tokens": { + "description": "List of generated unique tokens", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "models.GrantStarsRequest": { + "type": "object", + "properties": { + "amount": { + "type": "integer" + }, + "user_id": { + "type": "integer" + } + } + }, + "models.Option": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "is_correct": { + "type": "boolean" + }, + "text": { + "type": "string" + } + } + }, + "models.Purchase": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "purchased_at": { + "type": "string" + }, + "reward_id": { + "type": "integer" + }, + "stars_spent": { + "type": "integer" + }, + "status": { + "$ref": "#/definitions/models.PurchaseStatus" + }, + "user_id": { + "type": "integer" + } + } + }, + "models.PurchaseStatus": { + "type": "string", + "enum": [ + "pending", + "delivered", + "cancelled" + ], + "x-enum-varnames": [ + "Pending", + "Delivered", + "Cancelled" + ] + }, + "models.QRValidateRequest": { + "type": "object", + "properties": { + "payload": { + "type": "string" + } + } + }, + "models.Question": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "options": { + "description": "Stored as JSONB", + "type": "array", + "items": { + "$ref": "#/definitions/models.Option" + } + }, + "order_index": { + "type": "integer" + }, + "quiz_id": { + "type": "integer" + }, + "text": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/models.QuestionType" + } + } + }, + "models.QuestionType": { + "type": "string", + "enum": [ + "single", + "multiple" + ], + "x-enum-varnames": [ + "Single", + "Multiple" + ] + }, + "models.Quiz": { + "type": "object", + "properties": { + "can_repeat": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "has_timer": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "image_url": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "questions": { + "description": "This field is for API responses, not DB storage", + "type": "array", + "items": { + "$ref": "#/definitions/models.Question" + } + }, + "repeat_cooldown_hours": { + "type": "integer" + }, + "reward_stars": { + "type": "integer" + }, + "timer_per_question": { + "type": "integer" + }, + "title": { + "type": "string" + } + } + }, + "models.Reward": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "created_by": { + "type": "integer" + }, + "delivery_type": { + "$ref": "#/definitions/models.DeliveryType" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "image_url": { + "type": "string" + }, + "instructions": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "price_stars": { + "type": "integer" + }, + "stock": { + "type": "integer" + }, + "title": { + "type": "string" + } + } + }, + "models.SubmissionRequest": { + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "#/definitions/models.UserAnswer" + } + } + } + }, + "models.Transaction": { + "type": "object", + "properties": { + "amount": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/models.TransactionType" + } + } + }, + "models.TransactionType": { + "type": "string", + "enum": [ + "earned", + "spent" + ], + "x-enum-varnames": [ + "TransactionEarned", + "TransactionSpent" + ] + }, + "models.User": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "stars_balance": { + "type": "integer" + }, + "telegram_id": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "models.UserAnswer": { + "type": "object", + "properties": { + "option_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "question_id": { + "type": "integer" + } + } + } + }, + "securityDefinitions": { + "ApiKeyAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + }, + "BasicAuth": { + "type": "basic" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "localhost:8080", + BasePath: "/", + Schemes: []string{}, + Title: "Telegram Quiz Mini App API", + Description: "API для Telegram Mini App с викторинами, QR-сканированием и внутренней валютой", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json new file mode 100644 index 0000000..d60ed04 --- /dev/null +++ b/backend/docs/swagger.json @@ -0,0 +1,2076 @@ +{ + "swagger": "2.0", + "info": { + "description": "API для Telegram Mini App с викторинами, QR-сканированием и внутренней валютой", + "title": "Telegram Quiz Mini App API", + "contact": {}, + "version": "1.0" + }, + "host": "localhost:8080", + "basePath": "/", + "paths": { + "/api/admin/analytics": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns analytics data including user statistics, rewards, and quiz performance (admin/operator only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Get analytics data", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/admin/operators": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Creates a new operator account (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Create operator", + "parameters": [ + { + "description": "Create operator request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.CreateOperatorRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/admin/operators/{id}": { + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes an operator account (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Delete operator", + "parameters": [ + { + "type": "integer", + "description": "Operator Telegram ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/admin/qrcodes": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Generates unique QR codes for rewards, quizzes, or shop items (admin/operator only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "qr" + ], + "summary": "Generate QR codes", + "parameters": [ + { + "description": "QR codes generation request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.GenerateQRCodesRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.GenerateQRCodesResponse" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/admin/quizzes": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Creates a new quiz (admin/operator only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "quizzes" + ], + "summary": "Create a new quiz", + "parameters": [ + { + "description": "Quiz object", + "name": "quiz", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Quiz" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.Quiz" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/admin/quizzes/{id}": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates an existing quiz (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "quizzes" + ], + "summary": "Update a quiz", + "parameters": [ + { + "type": "integer", + "description": "Quiz ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Updated quiz object", + "name": "quiz", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Quiz" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.Quiz" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes a quiz (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "quizzes" + ], + "summary": "Delete a quiz", + "parameters": [ + { + "type": "integer", + "description": "Quiz ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/admin/quizzes/{quiz_id}/questions": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Adds a new question to a quiz (admin/operator only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "questions" + ], + "summary": "Create question", + "parameters": [ + { + "type": "integer", + "description": "Quiz ID", + "name": "quiz_id", + "in": "path", + "required": true + }, + { + "description": "Question object", + "name": "question", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Question" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.Question" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/admin/rewards": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Creates a new reward that users can purchase with stars (admin/operator only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rewards" + ], + "summary": "Create a new reward", + "parameters": [ + { + "description": "Reward object", + "name": "reward", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Reward" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.Reward" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/admin/rewards/{id}": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Updates an existing reward (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rewards" + ], + "summary": "Update a reward", + "parameters": [ + { + "type": "integer", + "description": "Reward ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Updated reward object", + "name": "reward", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.Reward" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.Reward" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + }, + "delete": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Deletes a reward (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rewards" + ], + "summary": "Delete a reward", + "parameters": [ + { + "type": "integer", + "description": "Reward ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/admin/users/grant-stars": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Manually grants stars to a user (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Grant stars to user", + "parameters": [ + { + "description": "Grant stars request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.GrantStarsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/auth/me": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns information about the currently authenticated Telegram user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Get current authenticated user", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/auth/validate": { + "post": { + "description": "Validates Telegram WebApp init data and returns user information", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Validate Telegram WebApp init data", + "parameters": [ + { + "description": "Init data validation request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "type": "object", + "properties": { + "initData": { + "type": "string" + } + } + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/me": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns the current user's profile information including balance", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get current user profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.User" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/qr/validate": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Validates a QR code payload and processes the associated action", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "qr" + ], + "summary": "Validate QR code payload", + "parameters": [ + { + "description": "QR validation request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.QRValidateRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/quizzes": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of all active quizzes", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "quizzes" + ], + "summary": "Get all active quizzes", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Quiz" + } + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/quizzes/{id}": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a single quiz with all its questions", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "quizzes" + ], + "summary": "Get quiz by ID", + "parameters": [ + { + "type": "integer", + "description": "Quiz ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.Quiz" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/quizzes/{id}/can-repeat": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Checks if user can repeat a quiz and when it will be available", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "quizzes" + ], + "summary": "Check if quiz can be repeated", + "parameters": [ + { + "type": "integer", + "description": "Quiz ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.CanRepeatResponse" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/quizzes/{id}/submit": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Submits quiz answers and calculates score/stars earned", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "quizzes" + ], + "summary": "Submit quiz answers", + "parameters": [ + { + "type": "integer", + "description": "Quiz ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Quiz submission", + "name": "submission", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.SubmissionRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/rewards": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns a list of all active rewards available for purchase", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rewards" + ], + "summary": "Get all active rewards", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Reward" + } + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/rewards/{id}/purchase": { + "post": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Allows a user to purchase a reward using their stars", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "rewards" + ], + "summary": "Purchase a reward", + "parameters": [ + { + "type": "integer", + "description": "Reward ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.Purchase" + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/user/purchases": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns the current user's purchase history", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get user purchase history", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Purchase" + } + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + }, + "/api/user/transactions": { + "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], + "description": "Returns the current user's transaction history (earned/spent stars)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Get user transaction history", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Transaction" + } + }, + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "success": { + "type": "boolean" + } + } + } + } + } + } + } + }, + "definitions": { + "models.CanRepeatResponse": { + "type": "object", + "properties": { + "can_repeat": { + "type": "boolean" + }, + "next_available_at": { + "type": "string" + } + } + }, + "models.CreateOperatorRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "telegram_id": { + "type": "integer" + } + } + }, + "models.DeliveryType": { + "type": "string", + "enum": [ + "physical", + "digital" + ], + "x-enum-varnames": [ + "Physical", + "Digital" + ] + }, + "models.GenerateQRCodesRequest": { + "type": "object", + "properties": { + "count": { + "description": "Number of codes to generate", + "type": "integer" + }, + "type": { + "description": "e.g., \"reward\", \"quiz\"", + "type": "string" + }, + "value": { + "description": "e.g., \"50\" for reward, \"1\" for quiz", + "type": "string" + } + } + }, + "models.GenerateQRCodesResponse": { + "type": "object", + "properties": { + "tokens": { + "description": "List of generated unique tokens", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "models.GrantStarsRequest": { + "type": "object", + "properties": { + "amount": { + "type": "integer" + }, + "user_id": { + "type": "integer" + } + } + }, + "models.Option": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "is_correct": { + "type": "boolean" + }, + "text": { + "type": "string" + } + } + }, + "models.Purchase": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "purchased_at": { + "type": "string" + }, + "reward_id": { + "type": "integer" + }, + "stars_spent": { + "type": "integer" + }, + "status": { + "$ref": "#/definitions/models.PurchaseStatus" + }, + "user_id": { + "type": "integer" + } + } + }, + "models.PurchaseStatus": { + "type": "string", + "enum": [ + "pending", + "delivered", + "cancelled" + ], + "x-enum-varnames": [ + "Pending", + "Delivered", + "Cancelled" + ] + }, + "models.QRValidateRequest": { + "type": "object", + "properties": { + "payload": { + "type": "string" + } + } + }, + "models.Question": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "options": { + "description": "Stored as JSONB", + "type": "array", + "items": { + "$ref": "#/definitions/models.Option" + } + }, + "order_index": { + "type": "integer" + }, + "quiz_id": { + "type": "integer" + }, + "text": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/models.QuestionType" + } + } + }, + "models.QuestionType": { + "type": "string", + "enum": [ + "single", + "multiple" + ], + "x-enum-varnames": [ + "Single", + "Multiple" + ] + }, + "models.Quiz": { + "type": "object", + "properties": { + "can_repeat": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "integer" + }, + "description": { + "type": "string" + }, + "has_timer": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "image_url": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "questions": { + "description": "This field is for API responses, not DB storage", + "type": "array", + "items": { + "$ref": "#/definitions/models.Question" + } + }, + "repeat_cooldown_hours": { + "type": "integer" + }, + "reward_stars": { + "type": "integer" + }, + "timer_per_question": { + "type": "integer" + }, + "title": { + "type": "string" + } + } + }, + "models.Reward": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "created_by": { + "type": "integer" + }, + "delivery_type": { + "$ref": "#/definitions/models.DeliveryType" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "image_url": { + "type": "string" + }, + "instructions": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "price_stars": { + "type": "integer" + }, + "stock": { + "type": "integer" + }, + "title": { + "type": "string" + } + } + }, + "models.SubmissionRequest": { + "type": "object", + "properties": { + "answers": { + "type": "array", + "items": { + "$ref": "#/definitions/models.UserAnswer" + } + } + } + }, + "models.Transaction": { + "type": "object", + "properties": { + "amount": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/models.TransactionType" + } + } + }, + "models.TransactionType": { + "type": "string", + "enum": [ + "earned", + "spent" + ], + "x-enum-varnames": [ + "TransactionEarned", + "TransactionSpent" + ] + }, + "models.User": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "stars_balance": { + "type": "integer" + }, + "telegram_id": { + "type": "integer" + }, + "username": { + "type": "string" + } + } + }, + "models.UserAnswer": { + "type": "object", + "properties": { + "option_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "question_id": { + "type": "integer" + } + } + } + }, + "securityDefinitions": { + "ApiKeyAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + }, + "BasicAuth": { + "type": "basic" + } + } +} \ No newline at end of file diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml new file mode 100644 index 0000000..9296356 --- /dev/null +++ b/backend/docs/swagger.yaml @@ -0,0 +1,1327 @@ +basePath: / +definitions: + models.CanRepeatResponse: + properties: + can_repeat: + type: boolean + next_available_at: + type: string + type: object + models.CreateOperatorRequest: + properties: + name: + type: string + telegram_id: + type: integer + type: object + models.DeliveryType: + enum: + - physical + - digital + type: string + x-enum-varnames: + - Physical + - Digital + models.GenerateQRCodesRequest: + properties: + count: + description: Number of codes to generate + type: integer + type: + description: e.g., "reward", "quiz" + type: string + value: + description: e.g., "50" for reward, "1" for quiz + type: string + type: object + models.GenerateQRCodesResponse: + properties: + tokens: + description: List of generated unique tokens + items: + type: string + type: array + type: object + models.GrantStarsRequest: + properties: + amount: + type: integer + user_id: + type: integer + type: object + models.Option: + properties: + id: + type: integer + is_correct: + type: boolean + text: + type: string + type: object + models.Purchase: + properties: + id: + type: integer + purchased_at: + type: string + reward_id: + type: integer + stars_spent: + type: integer + status: + $ref: '#/definitions/models.PurchaseStatus' + user_id: + type: integer + type: object + models.PurchaseStatus: + enum: + - pending + - delivered + - cancelled + type: string + x-enum-varnames: + - Pending + - Delivered + - Cancelled + models.QRValidateRequest: + properties: + payload: + type: string + type: object + models.Question: + properties: + id: + type: integer + options: + description: Stored as JSONB + items: + $ref: '#/definitions/models.Option' + type: array + order_index: + type: integer + quiz_id: + type: integer + text: + type: string + type: + $ref: '#/definitions/models.QuestionType' + type: object + models.QuestionType: + enum: + - single + - multiple + type: string + x-enum-varnames: + - Single + - Multiple + models.Quiz: + properties: + can_repeat: + type: boolean + created_at: + type: string + created_by: + type: integer + description: + type: string + has_timer: + type: boolean + id: + type: integer + image_url: + type: string + is_active: + type: boolean + questions: + description: This field is for API responses, not DB storage + items: + $ref: '#/definitions/models.Question' + type: array + repeat_cooldown_hours: + type: integer + reward_stars: + type: integer + timer_per_question: + type: integer + title: + type: string + type: object + models.Reward: + properties: + created_at: + type: string + created_by: + type: integer + delivery_type: + $ref: '#/definitions/models.DeliveryType' + description: + type: string + id: + type: integer + image_url: + type: string + instructions: + type: string + is_active: + type: boolean + price_stars: + type: integer + stock: + type: integer + title: + type: string + type: object + models.SubmissionRequest: + properties: + answers: + items: + $ref: '#/definitions/models.UserAnswer' + type: array + type: object + models.Transaction: + properties: + amount: + type: integer + created_at: + type: string + description: + type: string + type: + $ref: '#/definitions/models.TransactionType' + type: object + models.TransactionType: + enum: + - earned + - spent + type: string + x-enum-varnames: + - TransactionEarned + - TransactionSpent + models.User: + properties: + created_at: + type: string + first_name: + type: string + last_name: + type: string + stars_balance: + type: integer + telegram_id: + type: integer + username: + type: string + type: object + models.UserAnswer: + properties: + option_ids: + items: + type: integer + type: array + question_id: + type: integer + type: object +host: localhost:8080 +info: + contact: {} + description: API для Telegram Mini App с викторинами, QR-сканированием и внутренней + валютой + title: Telegram Quiz Mini App API + version: "1.0" +paths: + /api/admin/analytics: + get: + consumes: + - application/json + description: Returns analytics data including user statistics, rewards, and + quiz performance (admin/operator only) + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + data: + type: object + message: + type: string + success: + type: boolean + type: object + "500": + description: Internal Server Error + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Get analytics data + tags: + - admin + /api/admin/operators: + post: + consumes: + - application/json + description: Creates a new operator account (admin only) + parameters: + - description: Create operator request + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.CreateOperatorRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "400": + description: Bad Request + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "500": + description: Internal Server Error + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Create operator + tags: + - admin + /api/admin/operators/{id}: + delete: + consumes: + - application/json + description: Deletes an operator account (admin only) + parameters: + - description: Operator Telegram ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "400": + description: Bad Request + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "500": + description: Internal Server Error + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Delete operator + tags: + - admin + /api/admin/qrcodes: + post: + consumes: + - application/json + description: Generates unique QR codes for rewards, quizzes, or shop items (admin/operator + only) + parameters: + - description: QR codes generation request + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.GenerateQRCodesRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + data: + $ref: '#/definitions/models.GenerateQRCodesResponse' + message: + type: string + success: + type: boolean + type: object + "400": + description: Bad Request + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "500": + description: Internal Server Error + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Generate QR codes + tags: + - qr + /api/admin/quizzes: + post: + consumes: + - application/json + description: Creates a new quiz (admin/operator only) + parameters: + - description: Quiz object + in: body + name: quiz + required: true + schema: + $ref: '#/definitions/models.Quiz' + produces: + - application/json + responses: + "201": + description: Created + schema: + properties: + data: + $ref: '#/definitions/models.Quiz' + message: + type: string + success: + type: boolean + type: object + "400": + description: Bad Request + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "500": + description: Internal Server Error + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Create a new quiz + tags: + - quizzes + /api/admin/quizzes/{id}: + delete: + consumes: + - application/json + description: Deletes a quiz (admin only) + parameters: + - description: Quiz ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "400": + description: Bad Request + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "500": + description: Internal Server Error + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Delete a quiz + tags: + - quizzes + put: + consumes: + - application/json + description: Updates an existing quiz (admin only) + parameters: + - description: Quiz ID + in: path + name: id + required: true + type: integer + - description: Updated quiz object + in: body + name: quiz + required: true + schema: + $ref: '#/definitions/models.Quiz' + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + data: + $ref: '#/definitions/models.Quiz' + message: + type: string + success: + type: boolean + type: object + "400": + description: Bad Request + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "500": + description: Internal Server Error + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Update a quiz + tags: + - quizzes + /api/admin/quizzes/{quiz_id}/questions: + post: + consumes: + - application/json + description: Adds a new question to a quiz (admin/operator only) + parameters: + - description: Quiz ID + in: path + name: quiz_id + required: true + type: integer + - description: Question object + in: body + name: question + required: true + schema: + $ref: '#/definitions/models.Question' + produces: + - application/json + responses: + "201": + description: Created + schema: + properties: + data: + $ref: '#/definitions/models.Question' + message: + type: string + success: + type: boolean + type: object + "400": + description: Bad Request + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "500": + description: Internal Server Error + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Create question + tags: + - questions + /api/admin/rewards: + post: + consumes: + - application/json + description: Creates a new reward that users can purchase with stars (admin/operator + only) + parameters: + - description: Reward object + in: body + name: reward + required: true + schema: + $ref: '#/definitions/models.Reward' + produces: + - application/json + responses: + "201": + description: Created + schema: + properties: + data: + $ref: '#/definitions/models.Reward' + message: + type: string + success: + type: boolean + type: object + "400": + description: Bad Request + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "500": + description: Internal Server Error + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Create a new reward + tags: + - rewards + /api/admin/rewards/{id}: + delete: + consumes: + - application/json + description: Deletes a reward (admin only) + parameters: + - description: Reward ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "400": + description: Bad Request + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "500": + description: Internal Server Error + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Delete a reward + tags: + - rewards + put: + consumes: + - application/json + description: Updates an existing reward (admin only) + parameters: + - description: Reward ID + in: path + name: id + required: true + type: integer + - description: Updated reward object + in: body + name: reward + required: true + schema: + $ref: '#/definitions/models.Reward' + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + data: + $ref: '#/definitions/models.Reward' + message: + type: string + success: + type: boolean + type: object + "400": + description: Bad Request + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "500": + description: Internal Server Error + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Update a reward + tags: + - rewards + /api/admin/users/grant-stars: + post: + consumes: + - application/json + description: Manually grants stars to a user (admin only) + parameters: + - description: Grant stars request + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.GrantStarsRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "400": + description: Bad Request + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "500": + description: Internal Server Error + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Grant stars to user + tags: + - admin + /api/auth/me: + get: + consumes: + - application/json + description: Returns information about the currently authenticated Telegram + user + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + data: + type: object + message: + type: string + success: + type: boolean + type: object + "401": + description: Unauthorized + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Get current authenticated user + tags: + - auth + /api/auth/validate: + post: + consumes: + - application/json + description: Validates Telegram WebApp init data and returns user information + parameters: + - description: Init data validation request + in: body + name: request + required: true + schema: + properties: + initData: + type: string + type: object + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + data: + type: object + message: + type: string + success: + type: boolean + type: object + "400": + description: Bad Request + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "401": + description: Unauthorized + schema: + properties: + message: + type: string + success: + type: boolean + type: object + summary: Validate Telegram WebApp init data + tags: + - auth + /api/me: + get: + consumes: + - application/json + description: Returns the current user's profile information including balance + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + data: + $ref: '#/definitions/models.User' + message: + type: string + success: + type: boolean + type: object + "404": + description: Not Found + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Get current user profile + tags: + - user + /api/qr/validate: + post: + consumes: + - application/json + description: Validates a QR code payload and processes the associated action + parameters: + - description: QR validation request + in: body + name: request + required: true + schema: + $ref: '#/definitions/models.QRValidateRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + data: + type: object + message: + type: string + success: + type: boolean + type: object + "400": + description: Bad Request + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "401": + description: Unauthorized + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Validate QR code payload + tags: + - qr + /api/quizzes: + get: + consumes: + - application/json + description: Returns a list of all active quizzes + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + data: + items: + $ref: '#/definitions/models.Quiz' + type: array + message: + type: string + success: + type: boolean + type: object + "500": + description: Internal Server Error + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Get all active quizzes + tags: + - quizzes + /api/quizzes/{id}: + get: + consumes: + - application/json + description: Returns a single quiz with all its questions + parameters: + - description: Quiz ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + data: + $ref: '#/definitions/models.Quiz' + message: + type: string + success: + type: boolean + type: object + "400": + description: Bad Request + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "404": + description: Not Found + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "500": + description: Internal Server Error + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Get quiz by ID + tags: + - quizzes + /api/quizzes/{id}/can-repeat: + get: + consumes: + - application/json + description: Checks if user can repeat a quiz and when it will be available + parameters: + - description: Quiz ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + data: + $ref: '#/definitions/models.CanRepeatResponse' + message: + type: string + success: + type: boolean + type: object + "400": + description: Bad Request + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "500": + description: Internal Server Error + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Check if quiz can be repeated + tags: + - quizzes + /api/quizzes/{id}/submit: + post: + consumes: + - application/json + description: Submits quiz answers and calculates score/stars earned + parameters: + - description: Quiz ID + in: path + name: id + required: true + type: integer + - description: Quiz submission + in: body + name: submission + required: true + schema: + $ref: '#/definitions/models.SubmissionRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + data: + type: object + message: + type: string + success: + type: boolean + type: object + "400": + description: Bad Request + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "500": + description: Internal Server Error + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Submit quiz answers + tags: + - quizzes + /api/rewards: + get: + consumes: + - application/json + description: Returns a list of all active rewards available for purchase + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + data: + items: + $ref: '#/definitions/models.Reward' + type: array + message: + type: string + success: + type: boolean + type: object + "500": + description: Internal Server Error + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Get all active rewards + tags: + - rewards + /api/rewards/{id}/purchase: + post: + consumes: + - application/json + description: Allows a user to purchase a reward using their stars + parameters: + - description: Reward ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + data: + $ref: '#/definitions/models.Purchase' + message: + type: string + success: + type: boolean + type: object + "400": + description: Bad Request + schema: + properties: + message: + type: string + success: + type: boolean + type: object + "500": + description: Internal Server Error + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Purchase a reward + tags: + - rewards + /api/user/purchases: + get: + consumes: + - application/json + description: Returns the current user's purchase history + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + data: + items: + $ref: '#/definitions/models.Purchase' + type: array + message: + type: string + success: + type: boolean + type: object + "500": + description: Internal Server Error + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Get user purchase history + tags: + - user + /api/user/transactions: + get: + consumes: + - application/json + description: Returns the current user's transaction history (earned/spent stars) + produces: + - application/json + responses: + "200": + description: OK + schema: + properties: + data: + items: + $ref: '#/definitions/models.Transaction' + type: array + message: + type: string + success: + type: boolean + type: object + "500": + description: Internal Server Error + schema: + properties: + message: + type: string + success: + type: boolean + type: object + security: + - ApiKeyAuth: [] + summary: Get user transaction history + tags: + - user +securityDefinitions: + ApiKeyAuth: + in: header + name: Authorization + type: apiKey + BasicAuth: + type: basic +swagger: "2.0" diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..91c6ae3 --- /dev/null +++ b/backend/go.mod @@ -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 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..769bf07 --- /dev/null +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..09d43db --- /dev/null +++ b/backend/internal/config/config.go @@ -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 +} diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go new file mode 100644 index 0000000..e8a0b77 --- /dev/null +++ b/backend/internal/database/database.go @@ -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 +} diff --git a/backend/internal/handlers/admin_handler.go b/backend/internal/handlers/admin_handler.go new file mode 100644 index 0000000..7c073b1 --- /dev/null +++ b/backend/internal/handlers/admin_handler.go @@ -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, + }) +} diff --git a/backend/internal/handlers/auth_handler.go b/backend/internal/handlers/auth_handler.go new file mode 100644 index 0000000..6e01fff --- /dev/null +++ b/backend/internal/handlers/auth_handler.go @@ -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), + }, + }) +} \ No newline at end of file diff --git a/backend/internal/handlers/handlers.go b/backend/internal/handlers/handlers.go new file mode 100644 index 0000000..bf38919 --- /dev/null +++ b/backend/internal/handlers/handlers.go @@ -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. diff --git a/backend/internal/handlers/qr_handler.go b/backend/internal/handlers/qr_handler.go new file mode 100644 index 0000000..00cc883 --- /dev/null +++ b/backend/internal/handlers/qr_handler.go @@ -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, + }, + }) +} diff --git a/backend/internal/handlers/question_handler.go b/backend/internal/handlers/question_handler.go new file mode 100644 index 0000000..e52b75e --- /dev/null +++ b/backend/internal/handlers/question_handler.go @@ -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", + }) +} diff --git a/backend/internal/handlers/quiz_handler.go b/backend/internal/handlers/quiz_handler.go new file mode 100644 index 0000000..dc5f377 --- /dev/null +++ b/backend/internal/handlers/quiz_handler.go @@ -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", + }) +} diff --git a/backend/internal/handlers/reward_handler.go b/backend/internal/handlers/reward_handler.go new file mode 100644 index 0000000..ab0a813 --- /dev/null +++ b/backend/internal/handlers/reward_handler.go @@ -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", + }) +} diff --git a/backend/internal/handlers/user_handler.go b/backend/internal/handlers/user_handler.go new file mode 100644 index 0000000..7dc1fac --- /dev/null +++ b/backend/internal/handlers/user_handler.go @@ -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, + }) +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go new file mode 100644 index 0000000..2941484 --- /dev/null +++ b/backend/internal/middleware/auth.go @@ -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) +} \ No newline at end of file diff --git a/backend/internal/middleware/rbac.go b/backend/internal/middleware/rbac.go new file mode 100644 index 0000000..1d2285e --- /dev/null +++ b/backend/internal/middleware/rbac.go @@ -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) +} \ No newline at end of file diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go new file mode 100644 index 0000000..7965aa6 --- /dev/null +++ b/backend/internal/models/models.go @@ -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"` +} diff --git a/backend/internal/redis/redis.go b/backend/internal/redis/redis.go new file mode 100644 index 0000000..99e398b --- /dev/null +++ b/backend/internal/redis/redis.go @@ -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 +} diff --git a/backend/internal/repository/admin_repository.go b/backend/internal/repository/admin_repository.go new file mode 100644 index 0000000..931e20c --- /dev/null +++ b/backend/internal/repository/admin_repository.go @@ -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 + } +} \ No newline at end of file diff --git a/backend/internal/repository/purchase_repository.go b/backend/internal/repository/purchase_repository.go new file mode 100644 index 0000000..0b0abe8 --- /dev/null +++ b/backend/internal/repository/purchase_repository.go @@ -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 +} diff --git a/backend/internal/repository/qr_scan_repository.go b/backend/internal/repository/qr_scan_repository.go new file mode 100644 index 0000000..0876cae --- /dev/null +++ b/backend/internal/repository/qr_scan_repository.go @@ -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 +} diff --git a/backend/internal/repository/question_repository.go b/backend/internal/repository/question_repository.go new file mode 100644 index 0000000..73e6dc3 --- /dev/null +++ b/backend/internal/repository/question_repository.go @@ -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 +} diff --git a/backend/internal/repository/quiz_attempt_repository.go b/backend/internal/repository/quiz_attempt_repository.go new file mode 100644 index 0000000..0b84325 --- /dev/null +++ b/backend/internal/repository/quiz_attempt_repository.go @@ -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 +} diff --git a/backend/internal/repository/quiz_repository.go b/backend/internal/repository/quiz_repository.go new file mode 100644 index 0000000..430cc08 --- /dev/null +++ b/backend/internal/repository/quiz_repository.go @@ -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) +} diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go new file mode 100644 index 0000000..0582f7a --- /dev/null +++ b/backend/internal/repository/repository.go @@ -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 +} diff --git a/backend/internal/repository/reward_repository.go b/backend/internal/repository/reward_repository.go new file mode 100644 index 0000000..8765c09 --- /dev/null +++ b/backend/internal/repository/reward_repository.go @@ -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 +} \ No newline at end of file diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go new file mode 100644 index 0000000..1484a79 --- /dev/null +++ b/backend/internal/repository/user_repository.go @@ -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 +} \ No newline at end of file diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go new file mode 100644 index 0000000..6797dbb --- /dev/null +++ b/backend/internal/routes/routes.go @@ -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) +} diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go new file mode 100644 index 0000000..a01c43d --- /dev/null +++ b/backend/internal/service/admin_service.go @@ -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::` +// 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 +} diff --git a/backend/internal/service/qr_service.go b/backend/internal/service/qr_service.go new file mode 100644 index 0000000..d8747b7 --- /dev/null +++ b/backend/internal/service/qr_service.go @@ -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) + } +} diff --git a/backend/internal/service/question_service.go b/backend/internal/service/question_service.go new file mode 100644 index 0000000..2208813 --- /dev/null +++ b/backend/internal/service/question_service.go @@ -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) +} diff --git a/backend/internal/service/quiz_service.go b/backend/internal/service/quiz_service.go new file mode 100644 index 0000000..74a6f7e --- /dev/null +++ b/backend/internal/service/quiz_service.go @@ -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 +} diff --git a/backend/internal/service/reward_service.go b/backend/internal/service/reward_service.go new file mode 100644 index 0000000..4dbac7d --- /dev/null +++ b/backend/internal/service/reward_service.go @@ -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 +} diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go new file mode 100644 index 0000000..57399bc --- /dev/null +++ b/backend/internal/service/user_service.go @@ -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 +} diff --git a/backend/internal/types/roles.go b/backend/internal/types/roles.go new file mode 100644 index 0000000..b4f5cfb --- /dev/null +++ b/backend/internal/types/roles.go @@ -0,0 +1,10 @@ +package types + +// UserRole represents user roles +type UserRole string + +const ( + RoleAdmin UserRole = "admin" + RoleOperator UserRole = "operator" + RoleUser UserRole = "user" +) \ No newline at end of file diff --git a/backend/migrations/001_init_schema.sql b/backend/migrations/001_init_schema.sql new file mode 100644 index 0000000..95d7953 --- /dev/null +++ b/backend/migrations/001_init_schema.sql @@ -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"; \ No newline at end of file diff --git a/backend/migrations/002_add_photo_url_to_users.sql b/backend/migrations/002_add_photo_url_to_users.sql new file mode 100644 index 0000000..1978c47 --- /dev/null +++ b/backend/migrations/002_add_photo_url_to_users.sql @@ -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"; \ No newline at end of file diff --git a/backend/server b/backend/server new file mode 100755 index 0000000..e23f908 Binary files /dev/null and b/backend/server differ diff --git a/bot/.env.example b/bot/.env.example new file mode 100644 index 0000000..dc6420f --- /dev/null +++ b/bot/.env.example @@ -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 \ No newline at end of file diff --git a/bot/Dockerfile b/bot/Dockerfile new file mode 100644 index 0000000..7d75f28 --- /dev/null +++ b/bot/Dockerfile @@ -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"] \ No newline at end of file diff --git a/bot/README.md b/bot/README.md new file mode 100644 index 0000000..01c1af7 --- /dev/null +++ b/bot/README.md @@ -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 \ No newline at end of file diff --git a/bot/admin_handlers.py b/bot/admin_handlers.py new file mode 100644 index 0000000..832c64a --- /dev/null +++ b/bot/admin_handlers.py @@ -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) \ No newline at end of file diff --git a/bot/bot.py b/bot/bot.py new file mode 100644 index 0000000..eac74b7 --- /dev/null +++ b/bot/bot.py @@ -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}") \ No newline at end of file diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..ffc16fb --- /dev/null +++ b/bot/config.py @@ -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() \ No newline at end of file diff --git a/bot/examples.py b/bot/examples.py new file mode 100644 index 0000000..bf7780d --- /dev/null +++ b/bot/examples.py @@ -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() \ No newline at end of file diff --git a/bot/handlers.py b/bot/handlers.py new file mode 100644 index 0000000..01a99d2 --- /dev/null +++ b/bot/handlers.py @@ -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 = """ +🌟 Звёздные Викторины - Помощь + +🎯 Как начать: +Нажмите на кнопку "Открыть Викторины" и начните проходить викторины! + +🎁 Как получить звёзды: +• Проходите викторины +• Сканируйте QR-коды +• Участвуйте в акциях + +🛒 Как потратить звёзды: +• Обменивайте на призы в магазине +• Получайте скидки и бонусы + +📱 QR-коды: +• Сканируйте QR-коды через камеру телефона +• Или используйте встроенный сканер в приложении + +🔗 Deep Links: +• `reward_X` - получить X звёзд +• `quiz_X` - открыть викторину X +• `shop` - перейти в магазин + +❓ Вопросы? +Используйте кнопки ниже для доступа к мини-приложению! + """ + + 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 = """ +🎫 Информация о QR-кодах + +🔒 Система безопасности: +• QR-коды содержат уникальные токены +• Каждый токен одноразовый +• Срок действия - 30 дней +• Проверка на стороне сервера + +🎯 Типы QR-кодов: +• 💰 reward - начисление звёзд +• 🧠 quiz - открытие викторины +• 🛒 shop - действия в магазине + +📱 Как использовать: +1. Администратор генерирует QR-коды +2. QR-коды размещаются в нужных местах +3. Пользователи сканируют их через приложение +4. Приложение отправляет токен на валидацию + +⚡ Для администраторов: +Используйте /admin для генерации QR-кодов + """ + + await message.answer(info_text, reply_markup=create_main_keyboard()) \ No newline at end of file diff --git a/bot/qr_service.py b/bot/qr_service.py new file mode 100644 index 0000000..68295e1 --- /dev/null +++ b/bot/qr_service.py @@ -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, "" \ No newline at end of file diff --git a/bot/requirements.txt b/bot/requirements.txt new file mode 100644 index 0000000..6872fe0 --- /dev/null +++ b/bot/requirements.txt @@ -0,0 +1,3 @@ +aiogram==3.15.0 +python-dotenv==1.0.1 +aiohttp==3.10.10 \ No newline at end of file diff --git a/bot/utils.py b/bot/utils.py new file mode 100644 index 0000000..49002f6 --- /dev/null +++ b/bot/utils.py @@ -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": ( + "🌟 Добро пожаловать в Звёздные Викторины!\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() \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -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? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7959ce4 --- /dev/null +++ b/frontend/README.md @@ -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... + }, + }, +]) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..d94e7de --- /dev/null +++ b/frontend/eslint.config.js @@ -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, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..46b6d79 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + + Vite + React + TS + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a8a1b31 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..76bb1e1 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,2989 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@emotion/react': + specifier: ^11.14.0 + version: 11.14.0(@types/react@19.1.13)(react@19.1.1) + '@emotion/styled': + specifier: ^11.14.1 + version: 11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1) + '@mui/icons-material': + specifier: ^7.3.2 + version: 7.3.2(@mui/material@7.3.2(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@types/react@19.1.13)(react@19.1.1) + '@mui/material': + specifier: ^7.3.2 + version: 7.3.2(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@mui/x-data-grid': + specifier: ^8.11.2 + version: 8.11.2(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1))(@mui/material@7.3.2(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@mui/system@7.3.2(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@types/node': + specifier: ^24.5.1 + version: 24.5.1 + axios: + specifier: ^1.12.2 + version: 1.12.2 + html5-qrcode: + specifier: ^2.3.8 + version: 2.3.8 + lucide-react: + specifier: ^0.544.0 + version: 0.544.0(react@19.1.1) + react: + specifier: ^19.1.1 + version: 19.1.1 + react-dom: + specifier: ^19.1.1 + version: 19.1.1(react@19.1.1) + react-router-dom: + specifier: ^7.9.1 + version: 7.9.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + devDependencies: + '@eslint/js': + specifier: ^9.33.0 + version: 9.35.0 + '@types/react': + specifier: ^19.1.10 + version: 19.1.13 + '@types/react-dom': + specifier: ^19.1.7 + version: 19.1.9(@types/react@19.1.13) + '@vitejs/plugin-react': + specifier: ^5.0.0 + version: 5.0.2(vite@7.1.5(@types/node@24.5.1)) + eslint: + specifier: ^9.33.0 + version: 9.35.0 + eslint-plugin-react-hooks: + specifier: ^5.2.0 + version: 5.2.0(eslint@9.35.0) + eslint-plugin-react-refresh: + specifier: ^0.4.20 + version: 0.4.20(eslint@9.35.0) + globals: + specifier: ^16.3.0 + version: 16.4.0 + typescript: + specifier: ~5.8.3 + version: 5.8.3 + typescript-eslint: + specifier: ^8.39.1 + version: 8.44.0(eslint@9.35.0)(typescript@5.8.3) + vite: + specifier: ^7.1.2 + version: 7.1.5(@types/node@24.5.1) + +packages: + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.4': + resolution: {integrity: sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.4': + resolution: {integrity: sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.3': + resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.27.1': + resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.4': + resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.4': + resolution: {integrity: sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.4': + resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==} + engines: {node: '>=6.9.0'} + + '@emotion/babel-plugin@11.13.5': + resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} + + '@emotion/cache@11.14.0': + resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@emotion/is-prop-valid@1.4.0': + resolution: {integrity: sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==} + + '@emotion/memoize@0.9.0': + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + + '@emotion/react@11.14.0': + resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==} + peerDependencies: + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/serialize@1.3.3': + resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==} + + '@emotion/sheet@1.4.0': + resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} + + '@emotion/styled@11.14.1': + resolution: {integrity: sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==} + peerDependencies: + '@emotion/react': ^11.0.0-rc.0 + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/unitless@0.10.0': + resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0': + resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==} + peerDependencies: + react: '>=16.8.0' + + '@emotion/utils@1.4.2': + resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} + + '@emotion/weak-memoize@0.4.0': + resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + + '@esbuild/aix-ppc64@0.25.9': + resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.9': + resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.9': + resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.9': + resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.9': + resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.9': + resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.9': + resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.9': + resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.9': + resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.9': + resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.9': + resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.9': + resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.9': + resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.9': + resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.9': + resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.9': + resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.9': + resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.9': + resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.9': + resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.9': + resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.9': + resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.9': + resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.9': + resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.9': + resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.9': + resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.9': + resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.0': + resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.3.1': + resolution: {integrity: sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.15.2': + resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.1': + resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.35.0': + resolution: {integrity: sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.6': + resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.3.5': + resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@mui/core-downloads-tracker@7.3.2': + resolution: {integrity: sha512-AOyfHjyDKVPGJJFtxOlept3EYEdLoar/RvssBTWVAvDJGIE676dLi2oT/Kx+FoVXFoA/JdV7DEMq/BVWV3KHRw==} + + '@mui/icons-material@7.3.2': + resolution: {integrity: sha512-TZWazBjWXBjR6iGcNkbKklnwodcwj0SrChCNHc9BhD9rBgET22J1eFhHsEmvSvru9+opDy3umqAimQjokhfJlQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@mui/material': ^7.3.2 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/material@7.3.2': + resolution: {integrity: sha512-qXvbnawQhqUVfH1LMgMaiytP+ZpGoYhnGl7yYq2x57GYzcFL/iPzSZ3L30tlbwEjSVKNYcbiKO8tANR1tadjUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@mui/material-pigment-css': ^7.3.2 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@mui/material-pigment-css': + optional: true + '@types/react': + optional: true + + '@mui/private-theming@7.3.2': + resolution: {integrity: sha512-ha7mFoOyZGJr75xeiO9lugS3joRROjc8tG1u4P50dH0KR7bwhHznVMcYg7MouochUy0OxooJm/OOSpJ7gKcMvg==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/styled-engine@7.3.2': + resolution: {integrity: sha512-PkJzW+mTaek4e0nPYZ6qLnW5RGa0KN+eRTf5FA2nc7cFZTeM+qebmGibaTLrgQBy3UpcpemaqfzToBNkzuxqew==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.4.1 + '@emotion/styled': ^11.3.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + + '@mui/system@7.3.2': + resolution: {integrity: sha512-9d8JEvZW+H6cVkaZ+FK56R53vkJe3HsTpcjMUtH8v1xK6Y1TjzHdZ7Jck02mGXJsE6MQGWVs3ogRHTQmS9Q/rA==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + + '@mui/types@7.4.6': + resolution: {integrity: sha512-NVBbIw+4CDMMppNamVxyTccNv0WxtDb7motWDlMeSC8Oy95saj1TIZMGynPpFLePt3yOD8TskzumeqORCgRGWw==} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/utils@7.3.2': + resolution: {integrity: sha512-4DMWQGenOdLnM3y/SdFQFwKsCLM+mqxzvoWp9+x2XdEzXapkznauHLiXtSohHs/mc0+5/9UACt1GdugCX2te5g==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/x-data-grid@8.11.2': + resolution: {integrity: sha512-GSMDD4SuqTtsbZQMnTDFlVtewbPINmxTcGDeMQpJAQG8DMgV24UaoT1FYTqJiBkqnib6avz8ME0q/6i6BQGt3w==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.9.0 + '@emotion/styled': ^11.8.1 + '@mui/material': ^5.15.14 || ^6.0.0 || ^7.0.0 + '@mui/system': ^5.15.14 || ^6.0.0 || ^7.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + + '@mui/x-internals@8.11.2': + resolution: {integrity: sha512-3BFZ0Njgih+eWQBzSsdKEkRMlHtKRGFWz+/CGUrSBb5IApO0apkUSvG4v5augNYASsjksqWOXVlds7Wwznd0Lg==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@mui/x-virtualizer@0.1.6': + resolution: {integrity: sha512-rZBS1+Y8micorIDs06REFsjQGMW+CJ0OiRnko32R2fvC7Wy5FrgZ/PK5im1vXG6a86lSg5YLKT9wr59/6NqRjQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@popperjs/core@2.11.8': + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + + '@rolldown/pluginutils@1.0.0-beta.34': + resolution: {integrity: sha512-LyAREkZHP5pMom7c24meKmJCdhf2hEyvam2q0unr3or9ydwDL+DJ8chTF6Av/RFPb3rH8UFBdMzO5MxTZW97oA==} + + '@rollup/rollup-android-arm-eabi@4.50.2': + resolution: {integrity: sha512-uLN8NAiFVIRKX9ZQha8wy6UUs06UNSZ32xj6giK/rmMXAgKahwExvK6SsmgU5/brh4w/nSgj8e0k3c1HBQpa0A==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.50.2': + resolution: {integrity: sha512-oEouqQk2/zxxj22PNcGSskya+3kV0ZKH+nQxuCCOGJ4oTXBdNTbv+f/E3c74cNLeMO1S5wVWacSws10TTSB77g==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.50.2': + resolution: {integrity: sha512-OZuTVTpj3CDSIxmPgGH8en/XtirV5nfljHZ3wrNwvgkT5DQLhIKAeuFSiwtbMto6oVexV0k1F1zqURPKf5rI1Q==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.50.2': + resolution: {integrity: sha512-Wa/Wn8RFkIkr1vy1k1PB//VYhLnlnn5eaJkfTQKivirOvzu5uVd2It01ukeQstMursuz7S1bU+8WW+1UPXpa8A==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.50.2': + resolution: {integrity: sha512-QkzxvH3kYN9J1w7D1A+yIMdI1pPekD+pWx7G5rXgnIlQ1TVYVC6hLl7SOV9pi5q9uIDF9AuIGkuzcbF7+fAhow==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.50.2': + resolution: {integrity: sha512-dkYXB0c2XAS3a3jmyDkX4Jk0m7gWLFzq1C3qUnJJ38AyxIF5G/dyS4N9B30nvFseCfgtCEdbYFhk0ChoCGxPog==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.50.2': + resolution: {integrity: sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.50.2': + resolution: {integrity: sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.50.2': + resolution: {integrity: sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.50.2': + resolution: {integrity: sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.50.2': + resolution: {integrity: sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.50.2': + resolution: {integrity: sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.50.2': + resolution: {integrity: sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.50.2': + resolution: {integrity: sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.50.2': + resolution: {integrity: sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.50.2': + resolution: {integrity: sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.50.2': + resolution: {integrity: sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.50.2': + resolution: {integrity: sha512-nMKvq6FRHSzYfKLHZ+cChowlEkR2lj/V0jYj9JnGUVPL2/mIeFGmVM2mLaFeNa5Jev7W7TovXqXIG2d39y1KYA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.50.2': + resolution: {integrity: sha512-eFUvvnTYEKeTyHEijQKz81bLrUQOXKZqECeiWH6tb8eXXbZk+CXSG2aFrig2BQ/pjiVRj36zysjgILkqarS2YA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.50.2': + resolution: {integrity: sha512-cBaWmXqyfRhH8zmUxK3d3sAhEWLrtMjWBRwdMMHJIXSjvjLKvv49adxiEz+FJ8AP90apSDDBx2Tyd/WylV6ikA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.50.2': + resolution: {integrity: sha512-APwKy6YUhvZaEoHyM+9xqmTpviEI+9eL7LoCH+aLcvWYHJ663qG5zx7WzWZY+a9qkg5JtzcMyJ9z0WtQBMDmgA==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.5.1': + resolution: {integrity: sha512-/SQdmUP2xa+1rdx7VwB9yPq8PaKej8TD5cQ+XfKDPWWC+VDJU4rvVVagXqKUzhKjtFoNA8rXDJAkCxQPAe00+Q==} + + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@19.1.9': + resolution: {integrity: sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==} + peerDependencies: + '@types/react': ^19.0.0 + + '@types/react-transition-group@4.4.12': + resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==} + peerDependencies: + '@types/react': '*' + + '@types/react@19.1.13': + resolution: {integrity: sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==} + + '@typescript-eslint/eslint-plugin@8.44.0': + resolution: {integrity: sha512-EGDAOGX+uwwekcS0iyxVDmRV9HX6FLSM5kzrAToLTsr9OWCIKG/y3lQheCq18yZ5Xh78rRKJiEpP0ZaCs4ryOQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.44.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.44.0': + resolution: {integrity: sha512-VGMpFQGUQWYT9LfnPcX8ouFojyrZ/2w3K5BucvxL/spdNehccKhB4jUyB1yBCXpr2XFm0jkECxgrpXBW2ipoAw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.44.0': + resolution: {integrity: sha512-ZeaGNraRsq10GuEohKTo4295Z/SuGcSq2LzfGlqiuEvfArzo/VRrT0ZaJsVPuKZ55lVbNk8U6FcL+ZMH8CoyVA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.44.0': + resolution: {integrity: sha512-87Jv3E+al8wpD+rIdVJm/ItDBe/Im09zXIjFoipOjr5gHUhJmTzfFLuTJ/nPTMc2Srsroy4IBXwcTCHyRR7KzA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.44.0': + resolution: {integrity: sha512-x5Y0+AuEPqAInc6yd0n5DAcvtoQ/vyaGwuX5HE9n6qAefk1GaedqrLQF8kQGylLUb9pnZyLf+iEiL9fr8APDtQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.44.0': + resolution: {integrity: sha512-9cwsoSxJ8Sak67Be/hD2RNt/fsqmWnNE1iHohG8lxqLSNY8xNfyY7wloo5zpW3Nu9hxVgURevqfcH6vvKCt6yg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.44.0': + resolution: {integrity: sha512-ZSl2efn44VsYM0MfDQe68RKzBz75NPgLQXuGypmym6QVOWL5kegTZuZ02xRAT9T+onqvM6T8CdQk0OwYMB6ZvA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.44.0': + resolution: {integrity: sha512-lqNj6SgnGcQZwL4/SBJ3xdPEfcBuhCG8zdcwCPgYcmiPLgokiNDKlbPzCwEwu7m279J/lBYWtDYL+87OEfn8Jw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.44.0': + resolution: {integrity: sha512-nktOlVcg3ALo0mYlV+L7sWUD58KG4CMj1rb2HUVOO4aL3K/6wcD+NERqd0rrA5Vg06b42YhF6cFxeixsp9Riqg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.44.0': + resolution: {integrity: sha512-zaz9u8EJ4GBmnehlrpoKvj/E3dNbuQ7q0ucyZImm3cLqJ8INTc970B1qEqDX/Rzq65r3TvVTN7kHWPBoyW7DWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@5.0.2': + resolution: {integrity: sha512-tmyFgixPZCx2+e6VO9TNITWcCQl8+Nl/E8YbAyPVv85QCc7/A3JrdfG2A8gIzvVhWuzMOVrFW1aReaNxrI6tbw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.12.2: + resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} + + babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.8.4: + resolution: {integrity: sha512-L+YvJwGAgwJBV1p6ffpSTa2KRc69EeeYGYjRVWKs0GKrK+LON0GC0gV+rKSNtALEDvMDqkvCFq9r1r94/Gjwxw==} + hasBin: true + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.26.2: + resolution: {integrity: sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001743: + resolution: {integrity: sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + + cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.218: + resolution: {integrity: sha512-uwwdN0TUHs8u6iRgN8vKeWZMRll4gBkz+QMqdS7DDe49uiK68/UX92lFb61oiFPrpYZNeZIqa4bA7O6Aiasnzg==} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.25.9: + resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react-refresh@0.4.20: + resolution: {integrity: sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==} + peerDependencies: + eslint: '>=8.40' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.35.0: + resolution: {integrity: sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.4.0: + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + html5-qrcode@2.3.8: + resolution: {integrity: sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.544.0: + resolution: {integrity: sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.21: + resolution: {integrity: sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.1.1: + resolution: {integrity: sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==} + peerDependencies: + react: ^19.1.1 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@19.1.1: + resolution: {integrity: sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router-dom@7.9.1: + resolution: {integrity: sha512-U9WBQssBE9B1vmRjo9qTM7YRzfZ3lUxESIZnsf4VjR/lXYz9MHjvOxHzr/aUm4efpktbVOrF09rL/y4VHa8RMw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.9.1: + resolution: {integrity: sha512-pfAByjcTpX55mqSDGwGnY9vDCpxqBLASg0BMNAuMmpSGESo/TaOUG6BllhAtAkCGx8Rnohik/XtaqiYUJtgW2g==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + + react@19.1.1: + resolution: {integrity: sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==} + engines: {node: '>=0.10.0'} + + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.10: + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.50.2: + resolution: {integrity: sha512-BgLRGy7tNS9H66aIMASq1qSYbAAJV6Z6WR4QYTvj5FgF15rZ/ympT1uixHXwzbZUBDbkvqUI1KR0fH1FhMaQ9w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + scheduler@0.26.0: + resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.1: + resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@2.1.0: + resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.44.0: + resolution: {integrity: sha512-ib7mCkYuIzYonCq9XWF5XNw+fkj2zg629PSa9KNIQ47RXFF763S5BIX4wqz1+FLPogTZoiw8KmCiRPRa8bL3qw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.12.0: + resolution: {integrity: sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==} + + update-browserslist-db@1.1.3: + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-sync-external-store@1.5.0: + resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + vite@7.1.5: + resolution: {integrity: sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.27.1 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.4': {} + + '@babel/core@7.28.4': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.3 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.4 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.4 + '@babel/types': 7.28.4 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.3': + dependencies: + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.4 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.26.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.4 + '@babel/types': 7.28.4 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + '@babel/traverse': 7.28.4 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.27.1': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.4 + + '@babel/parser@7.28.4': + dependencies: + '@babel/types': 7.28.4 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.4)': + dependencies: + '@babel/core': 7.28.4 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/runtime@7.28.4': {} + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + + '@babel/traverse@7.28.4': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.3 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.4 + '@babel/template': 7.27.2 + '@babel/types': 7.28.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.4': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + + '@emotion/babel-plugin@11.13.5': + dependencies: + '@babel/helper-module-imports': 7.27.1 + '@babel/runtime': 7.28.4 + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/serialize': 1.3.3 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + transitivePeerDependencies: + - supports-color + + '@emotion/cache@11.14.0': + dependencies: + '@emotion/memoize': 0.9.0 + '@emotion/sheet': 1.4.0 + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + stylis: 4.2.0 + + '@emotion/hash@0.9.2': {} + + '@emotion/is-prop-valid@1.4.0': + dependencies: + '@emotion/memoize': 0.9.0 + + '@emotion/memoize@0.9.0': {} + + '@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@emotion/babel-plugin': 11.13.5 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.1.1) + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + hoist-non-react-statics: 3.3.2 + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.13 + transitivePeerDependencies: + - supports-color + + '@emotion/serialize@1.3.3': + dependencies: + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/unitless': 0.10.0 + '@emotion/utils': 1.4.2 + csstype: 3.1.3 + + '@emotion/sheet@1.4.0': {} + + '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@emotion/babel-plugin': 11.13.5 + '@emotion/is-prop-valid': 1.4.0 + '@emotion/react': 11.14.0(@types/react@19.1.13)(react@19.1.1) + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.1.1) + '@emotion/utils': 1.4.2 + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.13 + transitivePeerDependencies: + - supports-color + + '@emotion/unitless@0.10.0': {} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.1.1)': + dependencies: + react: 19.1.1 + + '@emotion/utils@1.4.2': {} + + '@emotion/weak-memoize@0.4.0': {} + + '@esbuild/aix-ppc64@0.25.9': + optional: true + + '@esbuild/android-arm64@0.25.9': + optional: true + + '@esbuild/android-arm@0.25.9': + optional: true + + '@esbuild/android-x64@0.25.9': + optional: true + + '@esbuild/darwin-arm64@0.25.9': + optional: true + + '@esbuild/darwin-x64@0.25.9': + optional: true + + '@esbuild/freebsd-arm64@0.25.9': + optional: true + + '@esbuild/freebsd-x64@0.25.9': + optional: true + + '@esbuild/linux-arm64@0.25.9': + optional: true + + '@esbuild/linux-arm@0.25.9': + optional: true + + '@esbuild/linux-ia32@0.25.9': + optional: true + + '@esbuild/linux-loong64@0.25.9': + optional: true + + '@esbuild/linux-mips64el@0.25.9': + optional: true + + '@esbuild/linux-ppc64@0.25.9': + optional: true + + '@esbuild/linux-riscv64@0.25.9': + optional: true + + '@esbuild/linux-s390x@0.25.9': + optional: true + + '@esbuild/linux-x64@0.25.9': + optional: true + + '@esbuild/netbsd-arm64@0.25.9': + optional: true + + '@esbuild/netbsd-x64@0.25.9': + optional: true + + '@esbuild/openbsd-arm64@0.25.9': + optional: true + + '@esbuild/openbsd-x64@0.25.9': + optional: true + + '@esbuild/openharmony-arm64@0.25.9': + optional: true + + '@esbuild/sunos-x64@0.25.9': + optional: true + + '@esbuild/win32-arm64@0.25.9': + optional: true + + '@esbuild/win32-ia32@0.25.9': + optional: true + + '@esbuild/win32-x64@0.25.9': + optional: true + + '@eslint-community/eslint-utils@4.9.0(eslint@9.35.0)': + dependencies: + eslint: 9.35.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.1': {} + + '@eslint/config-array@0.21.0': + dependencies: + '@eslint/object-schema': 2.1.6 + debug: 4.4.3 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.3.1': {} + + '@eslint/core@0.15.2': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.1': + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.35.0': {} + + '@eslint/object-schema@2.1.6': {} + + '@eslint/plugin-kit@0.3.5': + dependencies: + '@eslint/core': 0.15.2 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@mui/core-downloads-tracker@7.3.2': {} + + '@mui/icons-material@7.3.2(@mui/material@7.3.2(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@types/react@19.1.13)(react@19.1.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@mui/material': 7.3.2(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.13 + + '@mui/material@7.3.2(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@mui/core-downloads-tracker': 7.3.2 + '@mui/system': 7.3.2(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1) + '@mui/types': 7.4.6(@types/react@19.1.13) + '@mui/utils': 7.3.2(@types/react@19.1.13)(react@19.1.1) + '@popperjs/core': 2.11.8 + '@types/react-transition-group': 4.4.12(@types/react@19.1.13) + clsx: 2.1.1 + csstype: 3.1.3 + prop-types: 15.8.1 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + react-is: 19.1.1 + react-transition-group: 4.4.5(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.1.13)(react@19.1.1) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1) + '@types/react': 19.1.13 + + '@mui/private-theming@7.3.2(@types/react@19.1.13)(react@19.1.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@mui/utils': 7.3.2(@types/react@19.1.13)(react@19.1.1) + prop-types: 15.8.1 + react: 19.1.1 + optionalDependencies: + '@types/react': 19.1.13 + + '@mui/styled-engine@7.3.2(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1))(react@19.1.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/sheet': 1.4.0 + csstype: 3.1.3 + prop-types: 15.8.1 + react: 19.1.1 + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.1.13)(react@19.1.1) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1) + + '@mui/system@7.3.2(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@mui/private-theming': 7.3.2(@types/react@19.1.13)(react@19.1.1) + '@mui/styled-engine': 7.3.2(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1))(react@19.1.1) + '@mui/types': 7.4.6(@types/react@19.1.13) + '@mui/utils': 7.3.2(@types/react@19.1.13)(react@19.1.1) + clsx: 2.1.1 + csstype: 3.1.3 + prop-types: 15.8.1 + react: 19.1.1 + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.1.13)(react@19.1.1) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1) + '@types/react': 19.1.13 + + '@mui/types@7.4.6(@types/react@19.1.13)': + dependencies: + '@babel/runtime': 7.28.4 + optionalDependencies: + '@types/react': 19.1.13 + + '@mui/utils@7.3.2(@types/react@19.1.13)(react@19.1.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@mui/types': 7.4.6(@types/react@19.1.13) + '@types/prop-types': 15.7.15 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 19.1.1 + react-is: 19.1.1 + optionalDependencies: + '@types/react': 19.1.13 + + '@mui/x-data-grid@8.11.2(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1))(@mui/material@7.3.2(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@mui/system@7.3.2(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@mui/material': 7.3.2(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@mui/system': 7.3.2(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1) + '@mui/utils': 7.3.2(@types/react@19.1.13)(react@19.1.1) + '@mui/x-internals': 8.11.2(@types/react@19.1.13)(react@19.1.1) + '@mui/x-virtualizer': 0.1.6(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + clsx: 2.1.1 + prop-types: 15.8.1 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + use-sync-external-store: 1.5.0(react@19.1.1) + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.1.13)(react@19.1.1) + '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.1.13)(react@19.1.1))(@types/react@19.1.13)(react@19.1.1) + transitivePeerDependencies: + - '@types/react' + + '@mui/x-internals@8.11.2(@types/react@19.1.13)(react@19.1.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@mui/utils': 7.3.2(@types/react@19.1.13)(react@19.1.1) + react: 19.1.1 + reselect: 5.1.1 + use-sync-external-store: 1.5.0(react@19.1.1) + transitivePeerDependencies: + - '@types/react' + + '@mui/x-virtualizer@0.1.6(@types/react@19.1.13)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@babel/runtime': 7.28.4 + '@mui/utils': 7.3.2(@types/react@19.1.13)(react@19.1.1) + '@mui/x-internals': 8.11.2(@types/react@19.1.13)(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + transitivePeerDependencies: + - '@types/react' + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@popperjs/core@2.11.8': {} + + '@rolldown/pluginutils@1.0.0-beta.34': {} + + '@rollup/rollup-android-arm-eabi@4.50.2': + optional: true + + '@rollup/rollup-android-arm64@4.50.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.50.2': + optional: true + + '@rollup/rollup-darwin-x64@4.50.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.50.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.50.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.50.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.50.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.50.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.50.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.50.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.50.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.50.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.50.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.50.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.50.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.50.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.50.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.50.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.50.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.50.2': + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.4 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.4 + '@babel/types': 7.28.4 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.4 + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.5.1': + dependencies: + undici-types: 7.12.0 + + '@types/parse-json@4.0.2': {} + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@19.1.9(@types/react@19.1.13)': + dependencies: + '@types/react': 19.1.13 + + '@types/react-transition-group@4.4.12(@types/react@19.1.13)': + dependencies: + '@types/react': 19.1.13 + + '@types/react@19.1.13': + dependencies: + csstype: 3.1.3 + + '@typescript-eslint/eslint-plugin@8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.35.0)(typescript@5.8.3))(eslint@9.35.0)(typescript@5.8.3)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 8.44.0(eslint@9.35.0)(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.44.0 + '@typescript-eslint/type-utils': 8.44.0(eslint@9.35.0)(typescript@5.8.3) + '@typescript-eslint/utils': 8.44.0(eslint@9.35.0)(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.44.0 + eslint: 9.35.0 + graphemer: 1.4.0 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.1.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.44.0(eslint@9.35.0)(typescript@5.8.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.44.0 + '@typescript-eslint/types': 8.44.0 + '@typescript-eslint/typescript-estree': 8.44.0(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.44.0 + debug: 4.4.3 + eslint: 9.35.0 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.44.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.44.0(typescript@5.8.3) + '@typescript-eslint/types': 8.44.0 + debug: 4.4.3 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.44.0': + dependencies: + '@typescript-eslint/types': 8.44.0 + '@typescript-eslint/visitor-keys': 8.44.0 + + '@typescript-eslint/tsconfig-utils@8.44.0(typescript@5.8.3)': + dependencies: + typescript: 5.8.3 + + '@typescript-eslint/type-utils@8.44.0(eslint@9.35.0)(typescript@5.8.3)': + dependencies: + '@typescript-eslint/types': 8.44.0 + '@typescript-eslint/typescript-estree': 8.44.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.44.0(eslint@9.35.0)(typescript@5.8.3) + debug: 4.4.3 + eslint: 9.35.0 + ts-api-utils: 2.1.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.44.0': {} + + '@typescript-eslint/typescript-estree@8.44.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/project-service': 8.44.0(typescript@5.8.3) + '@typescript-eslint/tsconfig-utils': 8.44.0(typescript@5.8.3) + '@typescript-eslint/types': 8.44.0 + '@typescript-eslint/visitor-keys': 8.44.0 + debug: 4.4.3 + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.2 + ts-api-utils: 2.1.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.44.0(eslint@9.35.0)(typescript@5.8.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0) + '@typescript-eslint/scope-manager': 8.44.0 + '@typescript-eslint/types': 8.44.0 + '@typescript-eslint/typescript-estree': 8.44.0(typescript@5.8.3) + eslint: 9.35.0 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.44.0': + dependencies: + '@typescript-eslint/types': 8.44.0 + eslint-visitor-keys: 4.2.1 + + '@vitejs/plugin-react@5.0.2(vite@7.1.5(@types/node@24.5.1))': + dependencies: + '@babel/core': 7.28.4 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.4) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.4) + '@rolldown/pluginutils': 1.0.0-beta.34 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 7.1.5(@types/node@24.5.1) + transitivePeerDependencies: + - supports-color + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + asynckit@0.4.0: {} + + axios@1.12.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + babel-plugin-macros@3.1.0: + dependencies: + '@babel/runtime': 7.28.4 + cosmiconfig: 7.1.0 + resolve: 1.22.10 + + balanced-match@1.0.2: {} + + baseline-browser-mapping@2.8.4: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.26.2: + dependencies: + baseline-browser-mapping: 2.8.4 + caniuse-lite: 1.0.30001743 + electron-to-chromium: 1.5.218 + node-releases: 2.0.21 + update-browserslist-db: 1.1.3(browserslist@4.26.2) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001743: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + concat-map@0.0.1: {} + + convert-source-map@1.9.0: {} + + convert-source-map@2.0.0: {} + + cookie@1.0.2: {} + + cosmiconfig@7.1.0: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.1 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.1.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + delayed-stream@1.0.0: {} + + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.28.4 + csstype: 3.1.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.218: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.25.9: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.9 + '@esbuild/android-arm': 0.25.9 + '@esbuild/android-arm64': 0.25.9 + '@esbuild/android-x64': 0.25.9 + '@esbuild/darwin-arm64': 0.25.9 + '@esbuild/darwin-x64': 0.25.9 + '@esbuild/freebsd-arm64': 0.25.9 + '@esbuild/freebsd-x64': 0.25.9 + '@esbuild/linux-arm': 0.25.9 + '@esbuild/linux-arm64': 0.25.9 + '@esbuild/linux-ia32': 0.25.9 + '@esbuild/linux-loong64': 0.25.9 + '@esbuild/linux-mips64el': 0.25.9 + '@esbuild/linux-ppc64': 0.25.9 + '@esbuild/linux-riscv64': 0.25.9 + '@esbuild/linux-s390x': 0.25.9 + '@esbuild/linux-x64': 0.25.9 + '@esbuild/netbsd-arm64': 0.25.9 + '@esbuild/netbsd-x64': 0.25.9 + '@esbuild/openbsd-arm64': 0.25.9 + '@esbuild/openbsd-x64': 0.25.9 + '@esbuild/openharmony-arm64': 0.25.9 + '@esbuild/sunos-x64': 0.25.9 + '@esbuild/win32-arm64': 0.25.9 + '@esbuild/win32-ia32': 0.25.9 + '@esbuild/win32-x64': 0.25.9 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@5.2.0(eslint@9.35.0): + dependencies: + eslint: 9.35.0 + + eslint-plugin-react-refresh@0.4.20(eslint@9.35.0): + dependencies: + eslint: 9.35.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@9.35.0: + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0) + '@eslint-community/regexpp': 4.12.1 + '@eslint/config-array': 0.21.0 + '@eslint/config-helpers': 0.3.1 + '@eslint/core': 0.15.2 + '@eslint/eslintrc': 3.3.1 + '@eslint/js': 9.35.0 + '@eslint/plugin-kit': 0.3.5 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-root@1.1.0: {} + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + follow-redirects@1.15.11: {} + + form-data@4.0.4: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.4.0: {} + + gopd@1.2.0: {} + + graphemer@1.4.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + html5-qrcode@2.3.8: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + is-arrayish@0.2.1: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lines-and-columns@1.2.4: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.544.0(react@19.1.1): + dependencies: + react: 19.1.1 + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.21: {} + + object-assign@4.1.1: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-type@4.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proxy-from-env@1.1.0: {} + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-dom@19.1.1(react@19.1.1): + dependencies: + react: 19.1.1 + scheduler: 0.26.0 + + react-is@16.13.1: {} + + react-is@19.1.1: {} + + react-refresh@0.17.0: {} + + react-router-dom@7.9.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + react-router: 7.9.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + + react-router@7.9.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + cookie: 1.0.2 + react: 19.1.1 + set-cookie-parser: 2.7.1 + optionalDependencies: + react-dom: 19.1.1(react@19.1.1) + + react-transition-group@4.4.5(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + '@babel/runtime': 7.28.4 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + + react@19.1.1: {} + + reselect@5.1.1: {} + + resolve-from@4.0.0: {} + + resolve@1.22.10: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rollup@4.50.2: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.50.2 + '@rollup/rollup-android-arm64': 4.50.2 + '@rollup/rollup-darwin-arm64': 4.50.2 + '@rollup/rollup-darwin-x64': 4.50.2 + '@rollup/rollup-freebsd-arm64': 4.50.2 + '@rollup/rollup-freebsd-x64': 4.50.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.50.2 + '@rollup/rollup-linux-arm-musleabihf': 4.50.2 + '@rollup/rollup-linux-arm64-gnu': 4.50.2 + '@rollup/rollup-linux-arm64-musl': 4.50.2 + '@rollup/rollup-linux-loong64-gnu': 4.50.2 + '@rollup/rollup-linux-ppc64-gnu': 4.50.2 + '@rollup/rollup-linux-riscv64-gnu': 4.50.2 + '@rollup/rollup-linux-riscv64-musl': 4.50.2 + '@rollup/rollup-linux-s390x-gnu': 4.50.2 + '@rollup/rollup-linux-x64-gnu': 4.50.2 + '@rollup/rollup-linux-x64-musl': 4.50.2 + '@rollup/rollup-openharmony-arm64': 4.50.2 + '@rollup/rollup-win32-arm64-msvc': 4.50.2 + '@rollup/rollup-win32-ia32-msvc': 4.50.2 + '@rollup/rollup-win32-x64-msvc': 4.50.2 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + scheduler@0.26.0: {} + + semver@6.3.1: {} + + semver@7.7.2: {} + + set-cookie-parser@2.7.1: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + source-map-js@1.2.1: {} + + source-map@0.5.7: {} + + strip-json-comments@3.1.1: {} + + stylis@4.2.0: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@2.1.0(typescript@5.8.3): + dependencies: + typescript: 5.8.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.44.0(eslint@9.35.0)(typescript@5.8.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.44.0(@typescript-eslint/parser@8.44.0(eslint@9.35.0)(typescript@5.8.3))(eslint@9.35.0)(typescript@5.8.3) + '@typescript-eslint/parser': 8.44.0(eslint@9.35.0)(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 8.44.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.44.0(eslint@9.35.0)(typescript@5.8.3) + eslint: 9.35.0 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + typescript@5.8.3: {} + + undici-types@7.12.0: {} + + update-browserslist-db@1.1.3(browserslist@4.26.2): + dependencies: + browserslist: 4.26.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-sync-external-store@1.5.0(react@19.1.1): + dependencies: + react: 19.1.1 + + vite@7.1.5(@types/node@24.5.1): + dependencies: + esbuild: 0.25.9 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.50.2 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.5.1 + fsevents: 2.3.3 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yaml@1.10.2: {} + + yocto-queue@0.1.0: {} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/frontend/src/App.css @@ -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; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..e73a754 --- /dev/null +++ b/frontend/src/App.tsx @@ -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 ( + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + + ); +} + +export default App diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/DeepLinkHandler.tsx b/frontend/src/components/DeepLinkHandler.tsx new file mode 100644 index 0000000..fa87102 --- /dev/null +++ b/frontend/src/components/DeepLinkHandler.tsx @@ -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 = ({ onActionComplete }) => { + const { deepLinkAction, clearDeepLinkAction, updateUser } = useAuth(); + const navigate = useNavigate(); + const [processing, setProcessing] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(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 */} + + + + + Обработка действия... + + + + + {/* Result Modal */} + + + {error ? ( + <> + + + Ошибка + + + {error} + + + + ) : result?.type === 'reward' && ( + <> + + + Награда получена! + + + Вы получили {result.value} ⭐ + + + {result.message} + + + + )} + + {result?.type === 'quiz' && ( + <> + + + Викторина найдена! + + + {result.message} + + + Готовы пройти викторину? + + + + + + + )} + + {result?.type === 'shop' && ( + <> + + + Магазин призов + + + {result.message} + + + + )} + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/GridItem.tsx b/frontend/src/components/GridItem.tsx new file mode 100644 index 0000000..e4095e5 --- /dev/null +++ b/frontend/src/components/GridItem.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Grid, type GridProps } from '@mui/material'; + +interface GridItemProps extends Omit { + component?: React.ElementType; + xs?: number; + sm?: number; + md?: number; + lg?: number; + xl?: number; +} + +export const GridItem: React.FC = ({ children, component = 'div', ...props }) => { + return ( + + {React.createElement(component, {}, children)} + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx new file mode 100644 index 0000000..724588a --- /dev/null +++ b/frontend/src/components/layout/Layout.tsx @@ -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 = ({ children }) => { + const { isAuthenticated } = useAuth(); + + if (!isAuthenticated) { + return ( + + {children} + + ); + } + + return ( + + + {children} + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/layout/Navigation.tsx b/frontend/src/components/layout/Navigation.tsx new file mode 100644 index 0000000..ef849fe --- /dev/null +++ b/frontend/src/components/layout/Navigation.tsx @@ -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 ( + + + } + sx={{ + color: '#B0B0B0', + '&.Mui-selected': { + color: '#FFD700', + }, + '&:hover': { + backgroundColor: 'rgba(255, 215, 0, 0.1)', + transition: 'all 0.2s ease', + }, + }} + /> + } + sx={{ + color: '#B0B0B0', + '&.Mui-selected': { + color: '#FFD700', + }, + '&:hover': { + backgroundColor: 'rgba(255, 215, 0, 0.1)', + transition: 'all 0.2s ease', + }, + }} + /> + } + sx={{ + color: '#B0B0B0', + '&.Mui-selected': { + color: '#FFD700', + }, + '&:hover': { + backgroundColor: 'rgba(255, 215, 0, 0.1)', + transition: 'all 0.2s ease', + }, + }} + /> + } + sx={{ + color: '#B0B0B0', + '&.Mui-selected': { + color: '#FFD700', + }, + '&:hover': { + backgroundColor: 'rgba(255, 215, 0, 0.1)', + transition: 'all 0.2s ease', + }, + }} + /> + {isAdmin && ( + } + sx={{ + color: '#B0B0B0', + '&.Mui-selected': { + color: '#FFD700', + }, + '&:hover': { + backgroundColor: 'rgba(255, 215, 0, 0.1)', + transition: 'all 0.2s ease', + }, + }} + /> + )} + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/ui/AnswerOption.tsx b/frontend/src/components/ui/AnswerOption.tsx new file mode 100644 index 0000000..5583491 --- /dev/null +++ b/frontend/src/components/ui/AnswerOption.tsx @@ -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 = ({ + 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' ? ( + + ) : ( + + ); + } else if (isSelected && !isCorrect) { + return type === 'single' ? ( + + ) : ( + + ); + } + } + + if (isSelected) { + return type === 'single' ? ( + + ) : ( + + ); + } + + return type === 'single' ? ( + + ) : ( + + ); + }; + + return ( + + + {/* Icon */} + + {getIcon()} + + + {/* Text */} + + + {text} + + + + {/* Selection indicator */} + {isSelected && !showResult && ( + + )} + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/ui/CardQuiz.tsx b/frontend/src/components/ui/CardQuiz.tsx new file mode 100644 index 0000000..de7c0b9 --- /dev/null +++ b/frontend/src/components/ui/CardQuiz.tsx @@ -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 = ({ + quiz, + onStart, + isCompleted = false, + cooldownTime, + canStart = true, +}) => { + return ( + + {/* Image with gradient overlay */} + + + + + {/* Status badge */} + {isCompleted && ( + + )} + + + + {/* Title */} + + {quiz.title} + + + {/* Description */} + + {quiz.description} + + + {/* Info chips */} + + + } + 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 && ( + + } + 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', + }, + }} + /> + )} + + + {/* Action button */} + + {cooldownTime && !canStart ? ( + + + {cooldownTime} + + + ) : ( + canStart && onStart(quiz.id)} + > + + + {isCompleted ? 'Повторить' : canStart ? 'Начать' : 'Недоступно'} + + + + )} + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/ui/CardReward.tsx b/frontend/src/components/ui/CardReward.tsx new file mode 100644 index 0000000..7e07615 --- /dev/null +++ b/frontend/src/components/ui/CardReward.tsx @@ -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 = ({ + 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 ( + + {/* Image with overlay */} + + + + {/* Stock badge */} + {!inStock && ( + + )} + + {!isActive && ( + + )} + + {inStock && isActive && reward.stock > 0 && ( + + )} + + {inStock && isActive && reward.stock === -1 && ( + + )} + + {/* Delivery type badge */} + {reward.delivery_type && ( + + )} + + + + {/* Title */} + + {reward.title} + + + {/* Description */} + + {reward.description} + + + {/* Price */} + + + } + 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', + }, + }} + /> + + + {/* Action button */} + + + + + + + {!canAfford + ? `Не хватает ${reward.price_stars - userStars} ⭐` + : !inStock + ? 'Нет в наличии' + : !isActive + ? 'Недоступно' + : 'Купить'} + + + + + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/ui/HeaderProfile.tsx b/frontend/src/components/ui/HeaderProfile.tsx new file mode 100644 index 0000000..93feac8 --- /dev/null +++ b/frontend/src/components/ui/HeaderProfile.tsx @@ -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 = ({ + firstName, + lastName, + avatar, + starsBalance, +}) => { + return ( + + {/* Profile info */} + + + + + + Привет, {firstName}! 👋 + + + + + + {starsBalance.toLocaleString()} ⭐ + + + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/ui/QRScannerButton.tsx b/frontend/src/components/ui/QRScannerButton.tsx new file mode 100644 index 0000000..84d0a61 --- /dev/null +++ b/frontend/src/components/ui/QRScannerButton.tsx @@ -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 ( + + + + ); + } + + // Large button for desktop/tablet + return ( + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/ui/QuestionCard.tsx b/frontend/src/components/ui/QuestionCard.tsx new file mode 100644 index 0000000..3884fc2 --- /dev/null +++ b/frontend/src/components/ui/QuestionCard.tsx @@ -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 = ({ + question, + questionNumber, + totalQuestions, +}) => { + return ( + + + + {question} + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/ui/QuizProgress.tsx b/frontend/src/components/ui/QuizProgress.tsx new file mode 100644 index 0000000..8199bff --- /dev/null +++ b/frontend/src/components/ui/QuizProgress.tsx @@ -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 = ({ + 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 ( + + {/* Progress info */} + + + + Вопрос {currentQuestion} из {totalQuestions} + + + Прогресс: {Math.round(progress)}% + + + + {hasTimer && timeRemaining !== undefined && ( + + + + {formatTime(timeRemaining)} + + + )} + + + {/* Progress bar */} + + + + + {/* Question indicators */} + + {Array.from({ length: totalQuestions }, (_, index) => ( + + {index + 1} + + ))} + + + ); +}; \ No newline at end of file diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx new file mode 100644 index 0000000..268914f --- /dev/null +++ b/frontend/src/context/AuthContext.tsx @@ -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; + logout: () => void; + isAuthenticated: boolean; + isAdmin: boolean; + updateUser: (userData: Partial) => void; + deepLinkAction: { type: string; value: string } | null; + clearDeepLinkAction: () => void; +} + +const AuthContext = createContext(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 = ({ children }) => { + const [user, setUser] = useState(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 => { + 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) => { + 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 {children}; +}; \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..a0f386d --- /dev/null +++ b/frontend/src/index.css @@ -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; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + , +) diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx new file mode 100644 index 0000000..59a0fc1 --- /dev/null +++ b/frontend/src/pages/AdminPage.tsx @@ -0,0 +1,1232 @@ +import React, { useState, useEffect } from 'react'; +import { + Box, + Typography, + Card, + CardContent, + Tab, + Tabs, + Grid, + Paper, + Button, + TextField, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + IconButton, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Chip, +} from '@mui/material'; +import { GridItem } from '../components/GridItem'; +import { + Dashboard, + Quiz as QuizIcon, + ShoppingBag, + People, + Settings, + Add, + Edit, + Delete, + BarChart, + DragIndicator, +} from '@mui/icons-material'; +import { useAuth } from '../context/AuthContext'; +import { apiService } from '../services/api'; +import type { Quiz, Reward, Analytics, Question, Option } from '../types'; + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +const TabPanel: React.FC = ({ children, value, index }) => { + return ( + + ); +}; + +export const AdminPage: React.FC = () => { + const { user } = useAuth(); + const [value, setValue] = useState(0); + const [analytics, setAnalytics] = useState(null); + const [quizzes, setQuizzes] = useState([]); + const [rewards, setRewards] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Form states + const [newQuiz, setNewQuiz] = useState({ + title: '', + description: '', + image_url: '', + reward_stars: 10, + has_timer: false, + timer_per_question: 30, + can_repeat: false, + repeat_cooldown_hours: 24, + is_active: true, + }); + + const [questions, setQuestions] = useState([]); + const [currentQuestion, setCurrentQuestion] = useState>({ + text: '', + type: 'multiple', + options: [ + { id: 1, text: '', is_correct: false }, + { id: 2, text: '', is_correct: false }, + ], + order_index: 0, + }); + + const [newReward, setNewReward] = useState({ + title: '', + description: '', + image_url: '', + price_stars: 100, + delivery_type: 'digital' as 'physical' | 'digital', + instructions: '', + stock: -1, // -1 = infinite stock + is_active: true, + }); + + // Dialog states + const [quizDialogOpen, setQuizDialogOpen] = useState(false); + const [rewardDialogOpen, setRewardDialogOpen] = useState(false); + const [editingQuiz, setEditingQuiz] = useState(null); + const [editingReward, setEditingReward] = useState(null); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + const fetchData = async () => { + try { + const [analyticsResponse, quizzesResponse, rewardsResponse] = await Promise.all([ + apiService.getAnalytics(), + apiService.getAllQuizzes(), + apiService.getAllRewards(), + ]); + + if (analyticsResponse.success && analyticsResponse.data) { + setAnalytics(analyticsResponse.data); + } + + if (quizzesResponse.success && quizzesResponse.data) { + setQuizzes(quizzesResponse.data); + } + + if (rewardsResponse.success && rewardsResponse.data) { + setRewards(rewardsResponse.data); + } + } catch (err) { + console.error('Error fetching admin data:', err); + setError('Произошла ошибка при загрузке данных'); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + const handleChange = (_event: React.SyntheticEvent, newValue: number) => { + setValue(newValue); + }; + + const handleCreateQuiz = async () => { + if (questions.length === 0) { + setError('Добавьте хотя бы один вопрос'); + return; + } + + setSubmitting(true); + try { + const response = await apiService.createQuiz(newQuiz); + if (response.success) { + const quizId = response.data.id; + + // Create all questions for the quiz + for (const question of questions) { + await apiService.createQuestion(quizId, question); + } + + setQuizzes(prev => [...prev, { ...response.data, questions }]); + setQuizDialogOpen(false); + setNewQuiz({ + title: '', + description: '', + image_url: '', + reward_stars: 10, + has_timer: false, + timer_per_question: 30, + can_repeat: false, + repeat_cooldown_hours: 24, + is_active: true, + }); + resetQuestions(); + } else { + setError(response.message || 'Не удалось создать викторину'); + } + } catch (err) { + console.error('Error creating quiz:', err); + setError('Произошла ошибка при создании викторины'); + } finally { + setSubmitting(false); + } + }; + + const handleUpdateQuiz = async () => { + if (!editingQuiz) return; + + setSubmitting(true); + try { + const response = await apiService.updateQuiz(editingQuiz.id, editingQuiz); + if (response.success) { + // Get existing questions for the quiz + const quizResponse = await apiService.getQuizById(editingQuiz.id); + const existingQuestions = quizResponse.success ? quizResponse.data.questions || [] : []; + + // Delete questions that are no longer needed + for (const existingQuestion of existingQuestions) { + if (!questions.find(q => q.id === existingQuestion.id)) { + await apiService.deleteQuestion(editingQuiz.id, existingQuestion.id); + } + } + + // Update or create questions + for (const question of questions) { + if (question.id && question.id < 1000000) { // Existing question (not temporary ID) + await apiService.updateQuestion(editingQuiz.id, question.id, question); + } else { + // New question + await apiService.createQuestion(editingQuiz.id, question); + } + } + + setQuizzes(prev => prev.map(q => q.id === editingQuiz.id ? { ...response.data, questions } : q)); + setQuizDialogOpen(false); + setEditingQuiz(null); + resetQuestions(); + } else { + setError(response.message || 'Не удалось обновить викторину'); + } + } catch (err) { + console.error('Error updating quiz:', err); + setError('Произошла ошибка при обновлении викторины'); + } finally { + setSubmitting(false); + } + }; + + const handleDeleteQuiz = async (quizId: number) => { + if (!confirm('Вы уверены, что хотите удалить эту викторину?')) return; + + try { + const response = await apiService.deleteQuiz(quizId); + if (response.success) { + setQuizzes(prev => prev.filter(q => q.id !== quizId)); + } else { + setError(response.message || 'Не удалось удалить викторину'); + } + } catch (err) { + console.error('Error deleting quiz:', err); + setError('Произошла ошибка при удалении викторины'); + } + }; + + const handleCreateReward = async () => { + setSubmitting(true); + try { + const response = await apiService.createReward(newReward); + if (response.success) { + setRewards(prev => [...prev, response.data]); + setRewardDialogOpen(false); + setNewReward({ + title: '', + description: '', + image_url: '', + price_stars: 100, + delivery_type: 'digital', + instructions: '', + stock: -1, // -1 = infinite stock + is_active: true, + }); + } else { + setError(response.message || 'Не удалось создать приз'); + } + } catch (err) { + console.error('Error creating reward:', err); + setError('Произошла ошибка при создании приза'); + } finally { + setSubmitting(false); + } + }; + + const handleUpdateReward = async () => { + if (!editingReward) return; + + setSubmitting(true); + try { + const response = await apiService.updateReward(editingReward.id, editingReward); + if (response.success) { + setRewards(prev => prev.map(r => r.id === editingReward.id ? response.data : r)); + setRewardDialogOpen(false); + setEditingReward(null); + } else { + setError(response.message || 'Не удалось обновить приз'); + } + } catch (err) { + console.error('Error updating reward:', err); + setError('Произошла ошибка при обновлении приза'); + } finally { + setSubmitting(false); + } + }; + + const handleDeleteReward = async (rewardId: number) => { + if (!confirm('Вы уверены, что хотите удалить этот приз?')) return; + + try { + const response = await apiService.deleteReward(rewardId); + if (response.success) { + setRewards(prev => prev.filter(r => r.id !== rewardId)); + } else { + setError(response.message || 'Не удалось удалить приз'); + } + } catch (err) { + console.error('Error deleting reward:', err); + setError('Произошла ошибка при удалении приза'); + } + }; + + const openEditQuizDialog = async (quiz: Quiz) => { + setEditingQuiz(quiz); + + // Load questions for this quiz + try { + const response = await apiService.getQuizById(quiz.id); + if (response.success && response.data.questions) { + setQuestions(response.data.questions); + } else { + setQuestions([]); + } + } catch (err) { + console.error('Error loading questions:', err); + setQuestions([]); + } + + setQuizDialogOpen(true); + }; + + const openEditRewardDialog = (reward: Reward) => { + setEditingReward(reward); + setRewardDialogOpen(true); + }; + + // Question management functions + const addOption = () => { + const newId = Math.max(...(currentQuestion.options?.map(o => o.id) || [0]), 0) + 1; + setCurrentQuestion(prev => ({ + ...prev, + options: [...(prev.options || []), { id: newId, text: '', is_correct: false }] + })); + }; + + const removeOption = (optionId: number) => { + if (currentQuestion.options && currentQuestion.options.length > 2) { + setCurrentQuestion(prev => ({ + ...prev, + options: prev.options?.filter(o => o.id !== optionId) || [] + })); + } + }; + + const updateOption = (optionId: number, field: keyof Option, value: any) => { + setCurrentQuestion(prev => ({ + ...prev, + options: prev.options?.map(o => + o.id === optionId ? { ...o, [field]: value } : o + ) || [] + })); + }; + + const addQuestion = () => { + console.log('addQuestion called, currentQuestion:', currentQuestion); + console.log('questions before add:', questions.length); + + if (currentQuestion.text && currentQuestion.options && currentQuestion.options.length >= 2) { + const validOptions = currentQuestion.options.filter(o => o.text.trim() !== ''); + const hasCorrectAnswer = validOptions.some(o => o.is_correct); + + console.log('validOptions:', validOptions.length, 'hasCorrectAnswer:', hasCorrectAnswer); + + if (validOptions.length >= 2 && hasCorrectAnswer) { + const question: Question = { + id: Date.now(), // Temporary ID + quiz_id: editingQuiz?.id || 0, + text: currentQuestion.text, + type: 'multiple', + options: validOptions, + order_index: questions.length + }; + + setQuestions(prev => { + console.log('Setting questions, prev length:', prev.length, 'new length:', prev.length + 1); + return [...prev, question]; + }); + + setCurrentQuestion({ + text: '', + type: 'multiple', + options: [ + { id: 1, text: '', is_correct: false }, + { id: 2, text: '', is_correct: false }, + ], + order_index: questions.length + 1 + }); + } + } + }; + + const removeQuestion = (index: number) => { + setQuestions(prev => prev.filter((_, i) => i !== index)); + }; + + const resetQuestions = () => { + console.log('resetQuestions called'); + setQuestions([]); + setCurrentQuestion({ + text: '', + type: 'multiple', + options: [ + { id: 1, text: '', is_correct: false }, + { id: 2, text: '', is_correct: false }, + ], + order_index: 0 + }); + }; + + if (loading) { + return ( + + Загрузка... + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + return ( + + {/* Header */} + + + Админ-панель + + + Добро пожаловать, {user?.first_name}! + + + + {/* Stats Cards */} + {analytics && ( + + + + + + + + + {analytics.total_users} + + + Пользователи + + + + + + + + + + + + + + {analytics.total_quizzes_completed} + + + Викторин пройдено + + + + + + + + + + + + + + {analytics.total_stars_earned} + + + Звёзд выдано + + + + + + + + + + + + + + {analytics.total_purchases} + + + Покупок + + + + + + + + )} + + {/* Tabs */} + + + } + label="Обзор" + id="admin-tab-0" + aria-controls="admin-tabpanel-0" + /> + } + label="Викторины" + id="admin-tab-1" + aria-controls="admin-tabpanel-1" + /> + } + label="Призы" + id="admin-tab-2" + aria-controls="admin-tabpanel-2" + /> + } + label="Настройки" + id="admin-tab-3" + aria-controls="admin-tabpanel-3" + /> + + + + {/* Tab Panels */} + + + Обзор системы + + + + + Здесь будет отображаться подробная статистика и аналитика системы. + + + + + + + + + Управление викторинами + + + + + + + + + Название + Вопросов + Награда + Статус + Действия + + + + {quizzes.map((quiz) => ( + + {quiz.title} + {quiz.questions?.length || 0} + {quiz.reward_stars} ⭐ + + + + + openEditQuizDialog(quiz)} + > + + + handleDeleteQuiz(quiz.id)} + > + + + + + ))} + +
+
+
+ + + + + Управление призами + + + + + + + + + Название + Цена + Остаток + Статус + Действия + + + + {rewards.map((reward) => ( + + {reward.title} + {reward.price_stars} ⭐ + + {reward.stock === -1 ? '∞' : reward.stock} + + + + + + openEditRewardDialog(reward)} + > + + + handleDeleteReward(reward.id)} + > + + + + + ))} + +
+
+
+ + + + Настройки системы + + + + + Здесь будут настройки системы, управление операторами и другие административные функции. + + + + + + {/* Create Quiz Dialog */} + { + setQuizDialogOpen(false); + setEditingQuiz(null); + resetQuestions(); + }} + maxWidth="lg" + fullWidth + PaperProps={{ + sx: { + backgroundColor: '#1a1a1a', + border: '1px solid #333', + }, + }} + > + + {editingQuiz ? 'Редактировать викторину' : 'Создать викторину'} + + + { + if (editingQuiz) { + setEditingQuiz(prev => prev ? { ...prev, title: e.target.value } : null); + } else { + setNewQuiz(prev => ({ ...prev, title: e.target.value })); + } + }} + margin="normal" + sx={{ + '& .MuiOutlinedInput-root': { + '& fieldset': { borderColor: '#333' }, + '&:hover fieldset': { borderColor: '#FFD700' }, + }, + '& .MuiInputLabel-root': { color: '#888' }, + '& .MuiInputBase-input': { color: '#ffffff' }, + }} + /> + { + if (editingQuiz) { + setEditingQuiz(prev => prev ? { ...prev, description: e.target.value } : null); + } else { + setNewQuiz(prev => ({ ...prev, description: e.target.value })); + } + }} + margin="normal" + multiline + rows={3} + sx={{ + '& .MuiOutlinedInput-root': { + '& fieldset': { borderColor: '#333' }, + '&:hover fieldset': { borderColor: '#FFD700' }, + }, + '& .MuiInputLabel-root': { color: '#888' }, + '& .MuiInputBase-input': { color: '#ffffff' }, + }} + /> + { + if (editingQuiz) { + setEditingQuiz(prev => prev ? { ...prev, image_url: e.target.value } : null); + } else { + setNewQuiz(prev => ({ ...prev, image_url: e.target.value })); + } + }} + margin="normal" + sx={{ + '& .MuiOutlinedInput-root': { + '& fieldset': { borderColor: '#333' }, + '&:hover fieldset': { borderColor: '#FFD700' }, + }, + '& .MuiInputLabel-root': { color: '#888' }, + '& .MuiInputBase-input': { color: '#ffffff' }, + }} + /> + { + const value = parseInt(e.target.value) || 0; + if (editingQuiz) { + setEditingQuiz(prev => prev ? { ...prev, reward_stars: value } : null); + } else { + setNewQuiz(prev => ({ ...prev, reward_stars: value })); + } + }} + margin="normal" + sx={{ + '& .MuiOutlinedInput-root': { + '& fieldset': { borderColor: '#333' }, + '&:hover fieldset': { borderColor: '#FFD700' }, + }, + '& .MuiInputLabel-root': { color: '#888' }, + '& .MuiInputBase-input': { color: '#ffffff' }, + }} + /> + + {/* Questions Section */} + + + Вопросы ({questions.length}) + + + {/* Current Question Form */} + + + setCurrentQuestion(prev => ({ ...prev, text: e.target.value }))} + margin="normal" + sx={{ + '& .MuiOutlinedInput-root': { + '& fieldset': { borderColor: '#333' }, + '&:hover fieldset': { borderColor: '#FFD700' }, + }, + '& .MuiInputLabel-root': { color: '#888' }, + '& .MuiInputBase-input': { color: '#ffffff' }, + }} + /> + + + + Тип вопроса: Несколько ответов + + + + {/* Options */} + + + Варианты ответов: + + {currentQuestion.options?.map((option, index) => ( + + + updateOption(option.id, 'text', e.target.value)} + sx={{ + '& .MuiOutlinedInput-root': { + '& fieldset': { borderColor: '#333' }, + '&:hover fieldset': { borderColor: '#FFD700' }, + }, + '& .MuiInputBase-input': { color: '#ffffff' }, + }} + /> + updateOption(option.id, 'is_correct', !option.is_correct)} + sx={{ + color: option.is_correct ? '#4CAF50' : '#666', + backgroundColor: option.is_correct ? 'rgba(76, 175, 80, 0.1)' : 'transparent', + }} + > + ✓ + + {currentQuestion.options && currentQuestion.options.length > 2 && ( + removeOption(option.id)} + sx={{ color: '#f44336' }} + > + + + )} + + ))} + + + + + + + + + {/* Questions List */} + {questions.length > 0 && ( + + + Добавленные вопросы: + + {questions.map((question, index) => ( + + + + + + {index + 1}. {question.text} + + + {question.type === 'single' ? 'Один ответ' : 'Несколько ответов'} • {question.options.length} вариантов + + + removeQuestion(index)} + sx={{ color: '#f44336' }} + > + + + + + + ))} + + )} + + + + + + + + + {/* Create Reward Dialog */} + { + setRewardDialogOpen(false); + setEditingReward(null); + }} + maxWidth="md" + fullWidth + PaperProps={{ + sx: { + backgroundColor: '#1a1a1a', + border: '1px solid #333', + }, + }} + > + + {editingReward ? 'Редактировать приз' : 'Создать приз'} + + + { + if (editingReward) { + setEditingReward(prev => prev ? { ...prev, title: e.target.value } : null); + } else { + setNewReward(prev => ({ ...prev, title: e.target.value })); + } + }} + margin="normal" + sx={{ + '& .MuiOutlinedInput-root': { + '& fieldset': { borderColor: '#333' }, + '&:hover fieldset': { borderColor: '#FFD700' }, + }, + '& .MuiInputLabel-root': { color: '#888' }, + '& .MuiInputBase-input': { color: '#ffffff' }, + }} + /> + { + if (editingReward) { + setEditingReward(prev => prev ? { ...prev, description: e.target.value } : null); + } else { + setNewReward(prev => ({ ...prev, description: e.target.value })); + } + }} + margin="normal" + multiline + rows={3} + sx={{ + '& .MuiOutlinedInput-root': { + '& fieldset': { borderColor: '#333' }, + '&:hover fieldset': { borderColor: '#FFD700' }, + }, + '& .MuiInputLabel-root': { color: '#888' }, + '& .MuiInputBase-input': { color: '#ffffff' }, + }} + /> + { + if (editingReward) { + setEditingReward(prev => prev ? { ...prev, image_url: e.target.value } : null); + } else { + setNewReward(prev => ({ ...prev, image_url: e.target.value })); + } + }} + margin="normal" + sx={{ + '& .MuiOutlinedInput-root': { + '& fieldset': { borderColor: '#333' }, + '&:hover fieldset': { borderColor: '#FFD700' }, + }, + '& .MuiInputLabel-root': { color: '#888' }, + '& .MuiInputBase-input': { color: '#ffffff' }, + }} + /> + { + const value = parseInt(e.target.value) || 0; + if (editingReward) { + setEditingReward(prev => prev ? { ...prev, price_stars: value } : null); + } else { + setNewReward(prev => ({ ...prev, price_stars: value })); + } + }} + margin="normal" + sx={{ + '& .MuiOutlinedInput-root': { + '& fieldset': { borderColor: '#333' }, + '&:hover fieldset': { borderColor: '#FFD700' }, + }, + '& .MuiInputLabel-root': { color: '#888' }, + '& .MuiInputBase-input': { color: '#ffffff' }, + }} + /> + { + const value = e.target.value as 'physical' | 'digital'; + if (editingReward) { + setEditingReward(prev => prev ? { ...prev, delivery_type: value } : null); + } else { + setNewReward(prev => ({ ...prev, delivery_type: value })); + } + }} + margin="normal" + SelectProps={{ + native: true, + }} + sx={{ + '& .MuiOutlinedInput-root': { + '& fieldset': { borderColor: '#333' }, + '&:hover fieldset': { borderColor: '#FFD700' }, + }, + '& .MuiInputLabel-root': { color: '#888' }, + '& .MuiInputBase-input': { color: '#ffffff' }, + '& .MuiSelect-select': { color: '#ffffff' }, + }} + > + + + + { + if (editingReward) { + setEditingReward(prev => prev ? { ...prev, instructions: e.target.value } : null); + } else { + setNewReward(prev => ({ ...prev, instructions: e.target.value })); + } + }} + margin="normal" + multiline + rows={3} + sx={{ + '& .MuiOutlinedInput-root': { + '& fieldset': { borderColor: '#333' }, + '&:hover fieldset': { borderColor: '#FFD700' }, + }, + '& .MuiInputLabel-root': { color: '#888' }, + '& .MuiInputBase-input': { color: '#ffffff' }, + }} + /> + { + const value = parseInt(e.target.value) || 0; + if (editingReward) { + setEditingReward(prev => prev ? { ...prev, stock: value } : null); + } else { + setNewReward(prev => ({ ...prev, stock: value })); + } + }} + margin="normal" + sx={{ + '& .MuiOutlinedInput-root': { + '& fieldset': { borderColor: '#333' }, + '&:hover fieldset': { borderColor: '#FFD700' }, + }, + '& .MuiInputLabel-root': { color: '#888' }, + '& .MuiInputBase-input': { color: '#ffffff' }, + }} + /> + + + + + + +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx new file mode 100644 index 0000000..972c366 --- /dev/null +++ b/frontend/src/pages/HomePage.tsx @@ -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([]); + const [quizStatuses, setQuizStatuses] = useState<{ [key: number]: QuizStatus }>({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + return ( + + {/* Profile Header */} + {user && ( + + )} + + {/* Quizzes Grid */} + + {quizzes.map((quiz) => { + const quizStatus = getQuizStatus(quiz); + + return ( + + + + ); + })} + + + {/* Empty State */} + {quizzes.length === 0 && ( + + + Пока нет доступных викторин + + + Загляните позже или отсканируйте QR-код для получения звёзд + + + )} + + {/* QR Scanner Button */} + + + ); +}; \ No newline at end of file diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..38e276d --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -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(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 ( + + + 🌟 Звёздные Викторины + + + + Проходите викторины, сканируйте QR-коды и получайте звёзды! + + + {loading && ( + + + + Подключение к Telegram... + + + )} + + {error && ( + + {error} + + )} + + {!loading && !error && !window.Telegram?.WebApp?.initData && ( + + + Для продолжения необходимо открыть приложение через Telegram + + + + )} + + ); +}; \ No newline at end of file diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx new file mode 100644 index 0000000..1e7d931 --- /dev/null +++ b/frontend/src/pages/ProfilePage.tsx @@ -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 = ({ children, value, index }) => { + return ( + + ); +}; + +export const ProfilePage: React.FC = () => { + const { user, logout } = useAuth(); + const [value, setValue] = useState(0); + const [transactions, setTransactions] = useState([]); + const [purchases, setPurchases] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + return ( + + {/* Profile Header */} + + + + {user?.photo_url ? ( + User Avatar + ) : ( + + + + )} + + + {user?.first_name} {user?.last_name} + + + @{user?.username} + + + ID: {user?.telegram_id} + + + + + + + + + {user?.stars_balance} ⭐ + + + + + + + + {/* Tabs */} + + + } + label="История" + id="profile-tab-0" + aria-controls="profile-tabpanel-0" + /> + } + label="Покупки" + id="profile-tab-1" + aria-controls="profile-tabpanel-1" + /> + + + + {/* Tab Panels */} + + + + {transactions.map((transaction, index) => ( + + + {transaction.type === 'earned' ? ( + + ) : ( + + )} + + + {transaction.description} + + } + secondary={ + + {formatDate(transaction.created_at)} + + } + /> + + + ))} + + {transactions.length === 0 && ( + + + + Пока нет транзакций + + + )} + + + + + + + {purchases.map((purchase, index) => ( + + + + + + Покупка #{purchase.id} + + } + secondary={ + + {formatDate(purchase.purchased_at)} + + } + /> + + + + + + ))} + + {purchases.length === 0 && ( + + + + Пока нет покупок + + + )} + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/pages/QRScannerPage.tsx b/frontend/src/pages/QRScannerPage.tsx new file mode 100644 index 0000000..8a1fa45 --- /dev/null +++ b/frontend/src/pages/QRScannerPage.tsx @@ -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(null); + const [result, setResult] = useState(null); + const [showResult, setShowResult] = useState(false); + const scannerRef = useRef(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 ( + + {/* Header */} + + + 📷 QR-сканер + + + Наведите камеру на QR-код для сканирования + + + + {/* Scanner Container */} + +
+ + {scanning && ( + + + + + + + + + )} + + + {/* Controls */} + + {!scanning ? ( + + ) : ( + + )} + + + {/* Error Display */} + {error && ( + + {error} + + )} + + {/* Instructions */} + + + + Как использовать: + + + 1. Нажмите "Начать сканирование" + + + 2. Разрешите доступ к камере + + + 3. Наведите камеру на QR-код + + + 4. Дождитесь результата сканирования + + + + + {/* Result Modal */} + + + {result?.type === 'reward' && ( + <> + + + QR-код успешно отсканирован! + + + Вы получили {result.value} ⭐ + + + {result.message} + + + + )} + + {result?.type === 'open_quiz' && ( + <> + + + Викторина найдена! + + + {result.message} + + + Готовы пройти викторину? + + + + + + + )} + + {result?.type === 'error' && ( + <> + + + Ошибка! + + + {result.message} + + + + )} + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/pages/QuizPage.tsx b/frontend/src/pages/QuizPage.tsx new file mode 100644 index 0000000..370bbba --- /dev/null +++ b/frontend/src/pages/QuizPage.tsx @@ -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(null); + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [answers, setAnswers] = useState([]); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + + + + ); + } + + if (error || !quiz) { + return ( + + {error || 'Викторина не найдена'} + + ); + } + + 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 ( + + {/* Header */} + + + {quiz.title} + + + Вопрос {currentQuestionIndex + 1} из {quiz.questions?.length} + + + + {/* Question Card */} + + + {/* Answer Options */} + + {currentQuestion?.options.map((option) => ( + { + console.log('AnswerOption onSelect called:', optionId); + if (currentQuestion) { + handleAnswerChange(currentQuestion.id, optionId, currentQuestion.type === 'multiple'); + } + }} + /> + ))} + + + {/* Navigation Buttons */} + + + + + {!isLastQuestion ? ( + + ) : ( + + )} + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/pages/QuizResultPage.tsx b/frontend/src/pages/QuizResultPage.tsx new file mode 100644 index 0000000..11aa0b2 --- /dev/null +++ b/frontend/src/pages/QuizResultPage.tsx @@ -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 ( + + + + ); + } + + const { result, quizTitle } = state; + const percentage = result.total_questions > 0 + ? Math.round((result.correct_answers / result.total_questions) * 100) + : 0; + + return ( + + {/* Result Header */} + + + 🎉 Викторина завершена! + + + {quizTitle} + + + + {/* Result Card */} + + + {/* Score Circle */} + + + {percentage}% + + + + {/* Score Details */} + + + {result.correct_answers} из {result.total_questions} правильных ответов + + + Набрано очков: {result.score} + + + + {/* Stars Earned */} + + + + +{result.stars_earned} ⭐ начислено! + + + + + + {/* Performance Message */} + + + + = 80 ? '#FFD700' : percentage >= 60 ? '#4CAF50' : '#FF9800', + fontSize: 32, + }} + /> + + {percentage >= 80 + ? 'Отличный результат! 🏆' + : percentage >= 60 + ? 'Хорошая работа! 👍' + : 'Продолжайте тренироваться! 💪'} + + + + {percentage >= 80 + ? 'Вы настоящий знаток! Продолжайте в том же духе.' + : percentage >= 60 + ? 'Неплохой результат! С каждым разом будет лучше.' + : 'Практика делает мастера! Попробуйте еще раз.'} + + + + + {/* Action Buttons */} + + + + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/pages/ShopPage.tsx b/frontend/src/pages/ShopPage.tsx new file mode 100644 index 0000000..0872a44 --- /dev/null +++ b/frontend/src/pages/ShopPage.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedReward, setSelectedReward] = useState(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 ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + return ( + + {/* Header */} + + + Магазин призов 🛍️ + + + Ваш баланс: {user?.stars_balance} ⭐ + + + + {/* Rewards Grid */} + + {rewards.map((reward) => ( + + + + + + {reward.title} + + + + {reward.description} + + + {/* Price and Status */} + + + : } + label={reward.delivery_type === 'physical' ? 'Физический' : 'Цифровой'} + size="small" + sx={{ + backgroundColor: 'rgba(255, 255, 255, 0.1)', + color: '#ffffff', + }} + /> + {!isInStock(reward.stock) && ( + } + label="Нет в наличии" + size="small" + sx={{ + backgroundColor: 'rgba(244, 67, 54, 0.2)', + color: '#f44336', + }} + /> + )} + + + {/* Action Button */} + + + + + ))} + + + {rewards.length === 0 && ( + + + + Пока нет доступных призов + + + Загляните позже - призы появятся скоро! + + + )} + + {/* Purchase Confirmation Modal */} + setPurchaseModalOpen(false)} + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} + > + + + Подтверждение покупки + + + Вы уверены, что хотите купить "{selectedReward?.title}" за {selectedReward?.price_stars} ⭐? + + + + + + + + + {/* Instructions Modal */} + setInstructionModalOpen(false)} + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} + > + + + Покупка успешно оформлена! 🎉 + + + {selectedReward?.instructions} + + + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000..a7ba877 --- /dev/null +++ b/frontend/src/services/api.ts @@ -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> { + const response = await this.api.post('/auth/validate', { initData }); + return response.data; + } + + async getCurrentUser(): Promise> { + const response = await this.api.get('/auth/me'); + return response.data; + } + + // Quiz methods + async getAllQuizzes(): Promise> { + const response = await this.api.get('/quizzes'); + return response.data; + } + + async getQuizById(id: number): Promise> { + const response = await this.api.get(`/quizzes/${id}`); + return response.data; + } + + async submitQuiz(id: number, submissionData: any): Promise> { + const response = await this.api.post(`/quizzes/${id}/submit`, submissionData); + return response.data; + } + + async canRepeatQuiz(id: number): Promise> { + const response = await this.api.get(`/quizzes/${id}/can-repeat`); + return response.data; + } + + // Reward methods + async getAllRewards(): Promise> { + const response = await this.api.get('/rewards'); + return response.data; + } + + async purchaseReward(id: number): Promise> { + const response = await this.api.post(`/rewards/${id}/purchase`); + return response.data; + } + + // User methods + async getUserProfile(): Promise> { + const response = await this.api.get('/me'); + return response.data; + } + + async getUserTransactions(): Promise> { + const response = await this.api.get('/user/transactions'); + return response.data; + } + + async getUserPurchases(): Promise> { + const response = await this.api.get('/user/purchases'); + return response.data; + } + + // QR methods + async validateQR(payload: string): Promise> { + const response = await this.api.post('/qr/validate', { payload }); + return response.data; + } + + // Admin methods + async createQuiz(quiz: any): Promise> { + const response = await this.api.post('/admin/quizzes', quiz); + return response.data; + } + + async updateQuiz(id: number, quiz: any): Promise> { + const response = await this.api.put(`/admin/quizzes/${id}`, quiz); + return response.data; + } + + async deleteQuiz(id: number): Promise> { + const response = await this.api.delete(`/admin/quizzes/${id}`); + return response.data; + } + + async createQuestion(quizId: number, question: any): Promise> { + const response = await this.api.post(`/admin/quizzes/${quizId}/questions`, question); + return response.data; + } + + async updateQuestion(quizId: number, questionId: number, question: any): Promise> { + const response = await this.api.put(`/admin/quizzes/${quizId}/questions/${questionId}`, question); + return response.data; + } + + async deleteQuestion(quizId: number, questionId: number): Promise> { + const response = await this.api.delete(`/admin/quizzes/${quizId}/questions/${questionId}`); + return response.data; + } + + async createReward(reward: any): Promise> { + const response = await this.api.post('/admin/rewards', reward); + return response.data; + } + + async updateReward(id: number, reward: any): Promise> { + const response = await this.api.put(`/admin/rewards/${id}`, reward); + return response.data; + } + + async deleteReward(id: number): Promise> { + const response = await this.api.delete(`/admin/rewards/${id}`); + return response.data; + } + + async grantStars(data: any): Promise> { + const response = await this.api.post('/admin/users/grant-stars', data); + return response.data; + } + + async createOperator(data: any): Promise> { + const response = await this.api.post('/admin/operators', data); + return response.data; + } + + async deleteOperator(id: number): Promise> { + const response = await this.api.delete(`/admin/operators/${id}`); + return response.data; + } + + async getAnalytics(): Promise> { + const response = await this.api.get('/admin/analytics'); + return response.data; + } + + async generateQRCodes(data: any): Promise> { + 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> { + const response = await this.api.get('/auth/admin-role'); + return response.data; + } +} + +export const apiService = new ApiService(); +export default apiService; \ No newline at end of file diff --git a/frontend/src/styles/animations.css b/frontend/src/styles/animations.css new file mode 100644 index 0000000..6a6de5b --- /dev/null +++ b/frontend/src/styles/animations.css @@ -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; + } +} \ No newline at end of file diff --git a/frontend/src/theme/index.ts b/frontend/src/theme/index.ts new file mode 100644 index 0000000..ad46e3f --- /dev/null +++ b/frontend/src/theme/index.ts @@ -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; \ No newline at end of file diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..d10208d --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,159 @@ +// API Response types +export interface ApiResponse { + 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; + }[]; +} \ No newline at end of file diff --git a/frontend/src/types/telegram.ts b/frontend/src/types/telegram.ts new file mode 100644 index 0000000..311fc33 --- /dev/null +++ b/frontend/src/types/telegram.ts @@ -0,0 +1,61 @@ +// Re-export TelegramWebApp from vite-env.d.ts to maintain compatibility +/// + +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 }; \ No newline at end of file diff --git a/frontend/src/utils/telegram.ts b/frontend/src/utils/telegram.ts new file mode 100644 index 0000000..4dcc773 --- /dev/null +++ b/frontend/src/utils/telegram.ts @@ -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); + } + } + } + } +}; \ No newline at end of file diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..227a6c6 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -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"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..f85a399 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..cb6f0ee --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + allowedHosts: [] + } +}) diff --git a/setup_admin.sql b/setup_admin.sql new file mode 100644 index 0000000..797fa1d --- /dev/null +++ b/setup_admin.sql @@ -0,0 +1,11 @@ +-- Скрипт для добавления администратора в базу данных +-- Запустите этот SQL запрос в вашей PostgreSQL базе данных + +-- Добавляем администратора с вашим Telegram ID +INSERT INTO admins (telegram_id, name, role, added_by) +VALUES (6113992941, 'Admin', 'admin', 6113992941) +ON CONFLICT (telegram_id) DO UPDATE +SET role = 'admin', name = 'Admin'; + +-- Проверка, что пользователь добавлен +SELECT * FROM admins WHERE telegram_id = 6113992941; \ No newline at end of file diff --git a/tasks.md b/tasks.md new file mode 100644 index 0000000..810c8fd --- /dev/null +++ b/tasks.md @@ -0,0 +1,743 @@ +Вот подробно расписанное **Техническое задание (ТЗ)** для разработки Telegram Mini App с викторинами, QR-сканированием и внутренней валютой: + +--- +## 1. 🎯 Общее описание проекта + +Необходимо разработать **Telegram Mini App** — веб-приложение, встроенное в Telegram, где пользователи могут: + +- Проходить викторины и получать за них внутреннюю валюту. +- Сканировать QR-коды (вне приложения и внутри) для получения валюты. +- Тратить накопленную валюту в магазине на призы. +- Быстро открывать конкретные викторины по QR-коду. + +Приложение должно быть **быстрым**, **интуитивно понятным**, **мобильным** и **интегрированным с Telegram Web App API**. + +--- + +## 2. 🧩 Основные функциональные требования + +### 2.1. 🎓 Викторины + +- Пользователь может просматривать список доступных викторин. +- Каждая викторина имеет: + - Название + - Описание + - Количество вопросов + - Награду в валюте за прохождение (фиксированная или по количеству правильных ответов) + - Статус (доступна / уже пройдена / скоро доступна) +- Прохождение викторины: + - По одному вопросу на экране + - Варианты ответов (один или несколько — уточнить по типу вопроса) + - Таймер на вопрос (опционально, если требуется) + - По завершению — показ результатов и начисление валюты +- Повторное прохождение викторины — **запрещено** (или с ограничением, например, раз в N дней — уточнить). +- История пройденных викторин с датой и полученной наградой. + +### 2.2. 💰 Внутренняя валюта + +- Единая валюта для всей системы (например, “Коины”, “Звезды”, “Баллы” — название уточняется). +- Баланс отображается в шапке приложения. +- Начисляется: + - За прохождение викторин + - За сканирование QR-кодов +- Не сгорает, накапливается. +- История транзакций (начисления и траты). + +### 2.3. 🛒 Магазин призов + +- Раздел с товарами/призами, которые можно купить за валюту. +- Каждый приз имеет: + - Название + - Описание + - Изображение + - Стоимость в валюте + - Статус (доступен / закончился / скоро появится) +- При покупке: + - Списание валюты + - Уведомление об успешной покупке + - Инструкция по получению приза (например, код, ссылка, инструкция “забрать в университете” и т.п.) +- История покупок. + +### 2.4. 📷 QR-сканирование + +#### 2.4.1. Сканирование через приложение + +- В приложении есть кнопка “Сканировать QR-код”. +- При нажатии открывается камера телефона (с разрешения пользователя). +- Поддержка сканирования QR-кодов, содержащих: + - Просто награду (например, `reward:50`) + - Ссылку на открытие конкретной викторины (например, `quiz:id=123`) +- После сканирования: + - Если это награда — начисляется валюта, показывается уведомление. + - Если это викторина — открывается страница этой викторины с возможностью сразу начать прохождение. +- Логирование всех сканирований (дата, тип, ID пользователя, результат). + +#### 2.4.2. Сканирование вне приложения (через камеру телефона) + +- QR-коды должны содержать ссылку вида: + `https://t.me/your_bot?startapp=reward_50` + или + `https://t.me/your_bot?startapp=quiz_123` +- При открытии такой ссылки через камеру или другой сканер — открывается Telegram Mini App с параметром. +- Приложение должно обработать параметр: + - Если `reward_X` — начислить X валюты. + - Если `quiz_Y` — открыть викторину с ID=Y. +- Показать пользователю уведомление о результате (например: “+50 монет получено!” или “Викторина ‘Знаешь ли ты бренд?’ готова к прохождению!”). + +### 2.5. 🔗 Глубокие ссылки (Deep Links) + +- Поддержка параметров `startapp=...` для: + - Начисления валюты + - Открытия викторины + - Перехода в магазин + - Перехода к конкретному призу +- Все ссылки должны работать как из QR, так и из сообщений, постов, рекламы и т.п. + +--- + +## 3. 🖥️ Технические требования + +### 3.1. Платформа + +- Telegram Mini App (веб-приложение на React / Vue / Svelte / чистом JS — на выбор команды). +- Работает внутри Telegram на iOS, Android, Desktop. +- Адаптивный дизайн (мобильный first). + +### 3.2. Интеграции + +- Telegram Web App SDK (для получения данных пользователя, закрытия приложения, отправки данных и т.п.) +- Хранение данных: Firebase / Supabase / собственный бэкенд (уточнить). +- Аутентификация: через Telegram User ID (никаких логинов/паролей). + +### 3.3. QR-сканер + +- Использовать библиотеку типа `zxing-js/library` или `html5-qrcode` для встроенного сканирования. +- Запрос разрешения на камеру. +- Обработка ошибок (камера недоступна, QR не распознан и т.п.). + +### 3.4. Безопасность + +- Все действия пользователя привязаны к его `Telegram User ID`. +- Проверка на сервере при начислении валюты (чтобы нельзя было подделать запросы). +- Валидация QR-данных на стороне сервера (если QR генерируется динамически). + +### 3.5. Производительность + +- Быстрая загрузка приложения (< 2 сек). +- Минимизация размера бандла. +- Кэширование статики. +- Lazy loading для изображений и неосновных экранов. + +--- + +## 4. 🎨 UI/UX требования + +- Чистый, современный дизайн, адаптированный под Telegram. +- Минимум анимаций, но с приятными микро-интеракциями. +- Интуитивная навигация (вкладки: Викторины, Магазин, Профиль, Сканер). +- Уведомления о начислениях и действиях (всплывающие тосты). +- Темная/светлая тема — по системным настройкам пользователя. + +--- + +## 5. 📊 Административная часть (опционально, но желательно) + +- Панель администратора (отдельная или встроенная) для: + - Создания/редактирования викторин + - Настройки наград + - Генерации QR-кодов с наградами/викторинами + - Просмотра статистики (пользователи, активность, сканирования, покупки) + - Управления товарами в магазине + +--- + +## 6. 📅 Этапы разработки (ориентировочно) + +| Этап | Описание | Срок | +| ----------------------- | --------------------------------------------------- | -------- | +| 1. Прототип и дизайн | Создание wireframe, дизайн-макетов, согласование | 3–5 дней | +| 2. Ядро приложения | Настройка Telegram Mini App, авторизация, навигация | 3 дня | +| 3. Викторины | Создание, прохождение, начисление валюты | 5 дней | +| 4. QR-сканирование | Встроенное + обработка deep links | 4 дня | +| 5. Магазин и валюта | Баланс, покупки, история | 4 дня | +| 6. Админка (если нужно) | CRUD для контента | 5 дней | +| 7. Тестирование и фиксы | QA, оптимизация, багфиксы | 3–5 дней | +| 8. Деплой и запуск | Публикация, настройка бота, генерация QR | 2 дня | + +--- + +## 7. ✅ Ожидаемый результат + +- Работающее Telegram Mini App, доступное по ссылке через бота. +- Возможность проходить викторины и получать валюту. +- Возможность сканировать QR-коды как внутри приложения, так и через камеру телефона — с начислением валюты или открытием викторины. +- Магазин с обменом валюты на призы. +- Адаптивный интерфейс, быстрая работа, интуитивная навигация. +- Документация по генерации QR-кодов и deep links. + +--- + +## 8. 📌 Дополнительно (уточнить заказчику) + +- Название валюты? +- Будут ли викторины с таймером? +- Можно ли повторно проходить викторины? С каким интервалом? +- Типы вопросов: только один правильный ответ, или множественный выбор? +- Как пользователь получает призы из магазина? (физически, цифровой код, купон и т.п.) +- Нужна ли аналитика (Google Analytics, Яндекс.Метрика)? +- Нужна ли панель администратора? Если да — какие права доступа? + +--- + +## 9. 📬 Контакты и поддержка + +- После сдачи — техническая поддержка на 14–30 дней. +- Возможность доработок по отдельному соглашению. + +--- + +✅ **Готово к передаче команде разработки.** + +Если нужно — могу подготовить дизайн-макеты, структуру API, примеры QR-ссылок или спецификацию для бэкенда. + +--- + +# 🧩 ДОПОЛНЕННОЕ ТЕХНИЧЕСКОЕ ЗАДАНИЕ (v2.0) + +--- + +## 🔁 Обновления по ответам заказчика + +| Пункт | Ответ | Влияние на ТЗ | +|-------|-------|----------------| +| 1. Название валюты | **Звезды** ✨ | Все упоминания “валюты” → “Звёзды”. UI: иконка звезды, анимация при начислении. | +| 2. Таймер в викторинах | **Да, может быть** | Добавить в модель викторины поле `hasTimer: boolean`, `timerPerQuestion: number (сек)`. | +| 3. Повторное прохождение | **Да, с интервалом** | Добавить в модель викторины: `canRepeat: boolean`, `repeatCooldownHours: number`. Хранить дату последнего прохождения у пользователя. | +| 4. Типы вопросов | **Один или несколько ответов** | Вопросы имеют поле `type: "single" | "multiple"`. В UI — чекбоксы или радиокнопки. | +| 5. Получение призов | **Физически + цифровые инструкции** | В модели приза — поле `deliveryType: "physical" | "digital"` + `instructions: string`. | +| 6. Аналитика | **Не нужна** | Исключить GA/YM. Можно добавить внутреннюю аналитику в админку. | +| 7. Админка | **Да, с ролями: Админ → Операторы** | Добавить систему ролей. Админ может управлять операторами и балансами. Оператор — только контент. | + +--- + +## 🖼️ 1. ДИЗАЙН-СТРУКТУРА (Wireframe + Навигация) + +### 📱 Основные экраны: + +#### 1. Главная (Викторины) +- Шапка: аватарка пользователя, баланс ⭐ (например: “1250 ⭐”) +- Список викторин (карточки): + - Название + - Превью-картинка + - Награда (⭐) + - Таймер (если есть) + - Кнопка “Начать” / “Повторить через 3ч” / “Пройдено” +- Кнопка “Сканировать QR” внизу или в шапке + +#### 2. Экран викторины (вопрос) +- Прогресс: 3/10 +- Таймер (если включен): обратный отсчет +- Текст вопроса +- Варианты ответов (радио / чекбоксы) +- Кнопка “Далее” (активна после выбора) +- Кнопка “Назад” (если не первый вопрос) + +#### 3. Результаты викторины +- “Вы набрали 8/10!” +- “+250 ⭐ начислено!” (анимация звезды) +- Кнопка “В магазин” / “К викторинам” + +#### 4. Магазин +- Список призов (карточки): + - Название + - Изображение + - Цена в ⭐ + - Кнопка “Купить” (если хватает звезд) / “Недостаточно ⭐” +- При покупке — модалка с инструкцией получения приза + +#### 5. Профиль +- Имя, ID, аватар +- Баланс ⭐ +- История: + - Начисления (викторины, QR) + - Покупки +- Кнопка “Сканировать QR” + +#### 6. QR-сканер (встроенный) +- Fullscreen камера +- Оверлей с рамкой +- Кнопка “Отмена” +- При успешном сканировании — модалка с результатом + +#### 7. Админка (отдельный вход / скрытый URL) +- Авторизация по Telegram ID (белый список админов/операторов) +- Вкладки: + - Управление викторинами (CRUD) + - Управление призами (CRUD) + - Управление пользователями (поиск, ручное начисление/списание ⭐ — только для админа) + - Управление операторами (назначение/удаление — только для админа) + - Статистика (количество прохождений, сканирований, покупок) + +--- + +## 🌐 2. СПЕЦИФИКАЦИЯ API (если используется бэкенд) + +> Предполагаем, что бэкенд на Node.js / Python / Go, с REST API. + +### 🧑‍💻 Авторизация +- Все запросы — с `user_id` из Telegram WebApp initData (проверяется на бэкенде) + +### 🎯 Викторины + +``` +GET /quizzes — список всех активных викторин +GET /quizzes/{id} — детали викторины + вопросы +POST /quizzes/{id}/submit — отправка ответов → возвращает результат + начисленные ⭐ +GET /quizzes/{id}/can-repeat — проверка, можно ли повторить (возвращает { canRepeat: true, nextAvailableAt: timestamp }) +``` + +### 🛍️ Магазин + +``` +GET /rewards — список призов +POST /rewards/{id}/purchase — списание ⭐, создание записи о покупке +GET /user/purchases — история покупок пользователя +``` + +### ⭐ Баланс и транзакции + +``` +GET /user/balance — текущий баланс +GET /user/transactions — история (сортировка по дате) +POST /admin/grant-stars — (только админ) ручное начисление/списание (тело: { user_id, amount, reason }) +``` + +### 📷 QR и Deep Links + +``` +POST /qr/validate — принимает payload (например, “reward:50” или “quiz:123”), возвращает действие + данные +``` + +### 👨‍💼 Админка (требуется роль) + +``` +POST /admin/quizzes — создание викторины +PUT /admin/quizzes/{id} — редактирование +DELETE /admin/quizzes/{id} +POST /admin/rewards — создание приза +POST /admin/operators — назначение оператора (тело: { telegram_id, name }) +DELETE /admin/operators/{id} +GET /admin/analytics — статистика (кол-во пользователей, прохождений, сканирований, выданных ⭐ и т.п.) +``` + +--- + +## 🔗 3. ПРИМЕРЫ QR-ССЫЛОК И DEEP LINKS + +Все ссылки ведут на одного Telegram-бота (например, `@MyQuizBot`). + +### 🎁 Начисление звезд: +``` +https://t.me/MyQuizBot?startapp=reward_50 +https://t.me/MyQuizBot?startapp=reward_100 +``` + +### 🧠 Открытие викторины: +``` +https://t.me/MyQuizBot?startapp=quiz_123 +https://t.me/MyQuizBot?startapp=quiz_456 +``` + +### 🛒 Переход в магазин / к призу: +``` +https://t.me/MyQuizBot?startapp=shop +https://t.me/MyQuizBot?startapp=reward_789 // ID приза +``` + +> При открытии приложение парсит `startapp`, определяет тип и выполняет действие. + +--- + +## 👮 4. СТРУКТУРА АДМИНКИ + РОЛИ + +### 🧑‍💼 Роли: + +| Роль | Права | +|------|-------| +| **Администратор** | Полный доступ: CRUD викторин и призов, управление операторами, ручное изменение балансов пользователей, просмотр аналитики | +| **Оператор** | Только CRUD викторин и призов. Без доступа к финансам и пользователям. | + +### 🔐 Авторизация в админке: +- Только по белому списку Telegram ID (хранится в БД или .env). +- При открытии админки — проверка: если user.id есть в списке админов/операторов → показать соответствующий интерфейс. + +### 📊 Аналитика в админке (внутренняя, без GA): +- Графики: + - Активные пользователи (день/неделя/месяц) + - Количество прохождений викторин + - Количество сканирований QR + - Выдано ⭐ всего / потрачено в магазине + - ТОП-3 популярных викторин / призов + +--- + +## 🗃️ 5. СТРУКТУРА БАЗЫ ДАННЫХ (основные сущности) + +### 👤 Пользователи (`users`) +- `telegram_id` (PK) +- `username` +- `first_name`, `last_name` +- `stars_balance` +- `created_at` + +### 📚 Викторины (`quizzes`) +- `id` (PK) +- `title` +- `description` +- `image_url` +- `reward_stars` +- `has_timer`: boolean +- `timer_per_question`: int (сек) +- `can_repeat`: boolean +- `repeat_cooldown_hours`: int +- `is_active`: boolean +- `created_by` (operator_id) +- `created_at` + +### ❓ Вопросы (`questions`) +- `id` +- `quiz_id` +- `text` +- `type`: “single” | “multiple” +- `options`: JSON [{id, text, is_correct}] +- `order_index` + +### 📊 Попытки прохождения (`quiz_attempts`) +- `id` +- `user_id` +- `quiz_id` +- `score` +- `stars_earned` +- `completed_at` +- `answers`: JSON (лог выбранных вариантов) + +### 🎁 Призы (`rewards`) +- `id` +- `title` +- `description` +- `image_url` +- `price_stars` +- `delivery_type`: “physical” | “digital” +- `instructions`: text +- `stock`: int (0 = бесконечно) +- `is_active` +- `created_by` +- `created_at` + +### 🛒 Покупки (`purchases`) +- `id` +- `user_id` +- `reward_id` +- `stars_spent` +- `purchased_at` +- `status`: “pending” | “delivered” | “cancelled” + +### 📷 Сканирования QR (`qr_scans`) +- `id` +- `user_id` +- `type`: “reward” | “quiz” | “shop” +- `value`: “50”, “123”, etc. +- `scanned_at` +- `source`: “in_app” | “external” + +### 👮 Админы и операторы (`admins`) +- `telegram_id` (PK) +- `role`: “admin” | “operator” +- `name` +- `added_by` (admin who added) +- `added_at` + +Ниже — **детальное текстовое описание Figma-макетов** для вашего Telegram Mini App, которое вы (или дизайнер) сможете легко перенести в Figma. Я опишу **все экраны, компоненты, состояния, цвета, типографику и поведение** — как будто вы открываете готовый Figma-файл. + +--- + +# 🎨 FIGMA-МАКЕТЫ: Telegram Mini App “Звёздные Викторины” + +> 📱 Формат: Mobile (375x812 — iPhone 13/14), адаптивный под Telegram Web App +> 🎨 Стиль: Telegram-совместимый, минималистичный, с акцентами на звёзды и геймификацию + +--- + +## 🌈 Глобальные стили + +### Цвета: + +| Назначение | HEX | Применение | +|------------|-----|------------| +| Primary (Звёзды) | `#FFD700` | Иконки валюты, кнопки, акценты | +| Background | `#F5F5F5` (светлая) / `#1E1E1E` (тёмная) | Автоматически по системной теме | +| Text Primary | `#000000` / `#FFFFFF` | Основной текст | +| Text Secondary | `#757575` / `#B0B0B0` | Подписи, описания | +| Success | `#4CAF50` | Успешные действия | +| Error | `#F44336` | Ошибки, недостаточно звёзд | +| Border / Divider | `#E0E0E0` / `#333333` | Разделители | + +### Типографика: + +- **Основной шрифт**: `SF Pro Display` (iOS) / `Roboto` (Android) — системные +- Заголовки: SemiBold 20–24px +- Текст: Regular 16px +- Мелкий текст: Regular 12–14px + +### Иконки: + +- Использовать [Telegram UI Icons](https://core.telegram.org/bots/webapps#icons) или [Material Icons](https://fonts.google.com/icons) +- Кастомные иконки: звезда ⭐, QR-сканер, таймер, магазин, профиль + +--- + +## 📱 ЭКРАНЫ + +--- + +### 🖼️ ЭКРАН 1: Главная — Список викторин + +**Статус-бар**: +- Telegram-стиль: имя бота + кнопка “Назад” (если не корень) + +**Шапка**: +- Аватар пользователя (слева, круглый, 32x32) +- Приветствие: “Привет, [Имя]!” +- Баланс: “1250 ⭐” — большая жёлтая звезда рядом, крупный шрифт 20px SemiBold + +**Кнопка “Сканировать QR”**: +- Фиксированная внизу экрана +- Иконка сканера + текст “Сканировать QR” +- Цвет: Primary (жёлтый), закруглённые углы + +**Список карточек викторин**: + +Каждая карточка: +- Обложка (16:9, закруглённые углы) +- Наложение: градиент тёмный → прозрачный снизу +- Название (белый, SemiBold, поверх градиента) +- Награда: “+250 ⭐” (жёлтая звезда + текст, справа внизу) +- Таймер (если есть): иконка часов + “30 сек/вопрос” (серый текст) +- Статус-бейдж: + - “Начать” — зелёная кнопка + - “Повторить через 3ч” — серая кнопка с иконкой часов + - “Пройдено ✅” — зелёная галочка + +**Поведение**: +- При нажатии “Начать” → переход на экран первого вопроса +- При нажатии “Повторить через...” → тост: “Доступно через 3 часа” + +--- + +### 🖼️ ЭКРАН 2: Вопрос викторины + +**Прогресс-бар**: +- Сверху: “Вопрос 3 из 10” + ProgressBar (10 сегментов, текущий — жёлтый) + +**Таймер (если включен)**: +- Круговой таймер вокруг номера вопроса или отдельно сверху +- Обратный отсчёт: “00:25” — крупно, жёлтым, если <10 сек — мигает красным + +**Текст вопроса**: +- Центрирован, крупный шрифт 18px SemiBold, отступы по бокам + +**Варианты ответов**: +- Кнопки-карточки (по 1 на строку) +- Одиночный выбор: радиокнопка слева + текст +- Множественный: чекбокс + текст +- При выборе — подсветка рамки Primary-цветом +- Неактивные — серые + +**Кнопка “Далее”**: +- Снизу, активна только если выбран хотя бы один ответ +- При нажатии — переход к следующему вопросу / результатам + +**Кнопка “Назад”**: +- Только если вопрос не первый — в шапке слева + +--- + +### 🖼️ ЭКРАН 3: Результаты викторины + +**Фон**: +- Градиентный (жёлтый → оранжевый) или анимированная звёздная анимация + +**Текст**: +- “🎉 Поздравляем!” — крупно, 28px +- “Вы набрали 8 из 10!” — 20px SemiBold +- “+250 ⭐ начислено!” — с анимацией “всплывающей звезды” (можно сделать через CSS/JS позже) + +**Кнопки**: +- “К викторинам” — Outline-стиль +- “В магазин” — Solid Primary (жёлтая) + +**Анимация**: +- При открытии — звёзды “падают” сверху (визуальный эффект праздника) + +--- + +### 🖼️ ЭКРАН 4: Магазин призов + +**Шапка**: +- “Магазин призов” + иконка 🛒 + +**Сетка карточек (2 колонки)**: + +Каждая карточка: +- Изображение товара (1:1, закруглённые углы) +- Название (SemiBold 16px) +- Цена: “500 ⭐” (жёлтая звезда + крупный текст) +- Статус: “В наличии” / “Закончился” (серый/красный бейдж) +- Кнопка: “Купить” (активна, Primary) / “Недостаточно ⭐” (серая, неактивна) + +**При нажатии “Купить”**: +→ Открывается модалка: + +> **Подтверждение покупки** +> “Вы уверены, что хотите купить ‘Футболку бренда’ за 500 ⭐?” +> Кнопки: “Отмена” / “Купить” (красная) +> → При подтверждении: списание ⭐, переход на экран инструкции + +--- + +### 🖼️ ЭКРАН 5: Инструкция получения приза + +**Заголовок**: “Как получить приз?” + +**Текст**: +- “Приходите в наш офис по адресу: ул. Ленина, 10 с 10:00 до 18:00 и назовите код: **STAR-7B2F**” +- (или для цифрового: “Ваш промокод: **DIGI-9X8A**. Используйте его на сайте example.com”) + +**Кнопка**: “Скопировать код” + иконка буфера обмена +**Кнопка**: “Назад в магазин” + +--- + +### 🖼️ ЭКРАН 6: Профиль + +**Шапка**: +- Большой аватар (80x80) +- Имя + username +- “ID: 123456789” + +**Блок “Баланс”**: +- Крупно: “1250 ⭐” +- Кнопка “История” справа + +**История (вкладка)**: +- Вкладки: “Начисления” / “Покупки” +- Список: + - “Викторина ‘Бренды’ — +250 ⭐ — 12 мая 14:30” + - “QR-сканирование — +50 ⭐ — 12 мая 10:15” + - “Покупка: Футболка — -500 ⭐ — 11 мая 16:00” + +**Кнопка “Сканировать QR”** — внизу + +--- + +### 🖼️ ЭКРАН 7: QR-сканер (встроенный) + +**Фон**: +- Полноэкранный поток с камеры (черный, если камера не готова) + +**Оверлей**: +- Центральная рамка (квадрат, 240x240, с анимированными углами) +- Подсказка: “Наведите на QR-код” +- Кнопка “Отмена” — в шапке + +**При сканировании**: +→ Звук + вибрация → переход на модалку: + +> **Успех!** +> “+50 ⭐ получено!” +> (или “Открыта викторина ‘Знаешь ли ты бренд?’ — нажмите ‘Начать’”) +> Кнопки: “ОК” / “Начать” + +--- + +### 🖼️ ЭКРАН 8: Админка — Вход (скрытый) + +**Защищённый URL**: `/admin` — открывается только если Telegram ID в белом списке + +**Экран входа**: +- Логотип приложения +- “Добро пожаловать, [Имя]!” +- “Ваша роль: Администратор / Оператор” +- Кнопка “Перейти в панель” + +--- + +### 🖼️ ЭКРАН 9: Админка — Главная + +**Меню (слева или вкладки сверху)**: +- Викторины +- Призы +- Пользователи (только админ) +- Операторы (только админ) +- Аналитика + +**Главная статистика (дашборд)**: +- 4 карточки: + - Пользователей: 1 250 + - Пройдено викторин: 3 842 + - Выдано ⭐: 250 000 + - Куплено призов: 1 024 + +**Графики**: +- Линейный график: активность по дням (прохождения, сканирования, покупки) + +--- + +### 🖼️ ЭКРАН 10: Админка — Создание викторины (форма) + +**Поля**: + +- Название (текстовое поле) +- Описание (textarea) +- Обложка (загрузка изображения) +- Награда (число, ⭐) +- Таймер: чекбокс “Включить таймер” → поле “сек/вопрос” +- Повтор: чекбокс “Разрешить повтор” → поле “часов до повтора” +- Вопросы (динамический список — кнопка “+ Добавить вопрос”) + +**Вопрос**: +- Текст вопроса +- Тип: селект “Один ответ” / “Несколько ответов” +- Варианты: текст + чекбокс “Правильный” +- Кнопка “Удалить вопрос” + +**Кнопка**: “Сохранить викторину” + +--- + +## 🧩 Компоненты для переиспользования (в Figma — Components) + +1. **Карточка викторины** +2. **Карточка приза** +3. **Кнопка Primary / Secondary / Disabled** +4. **Модальное окно** +5. **Строка истории транзакции** +6. **Прогресс-бар вопросов** +7. **Таймер (круговой и линейный)** +8. **Бейдж статуса (Пройдено / Недоступно / В наличии)** + +--- + +## 📲 Адаптивность + +- Все экраны должны масштабироваться под разные размеры экранов (от 320px до 430px шириной) +- Отступы: 16px по бокам, 12–24px между элементами +- Шрифты: не менее 14px для читаемости + +--- + +## 🎁 Бонус: Анимации (опционально, для delight) + +- При начислении ⭐ — анимация “+50 ⭐” всплывает и исчезает +- При открытии викторины — плавный переход +- При сканировании QR — эффект “затухания” + звук