diff --git a/.env b/.env index 0322222..98f9cd2 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ BOT_TOKEN=8156642467:AAEalekr-33lFS3tTTkXIEiaawfDzR6lBJc TZ=Europe/Moscow -ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923 +ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923 \ No newline at end of file diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..b31a72b --- /dev/null +++ b/Dockerfile.api @@ -0,0 +1,18 @@ +FROM python:3.11-slim +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 +WORKDIR /app + +COPY requirements.txt ./ +COPY webapp/requirements-api.txt ./requirements-api.txt +RUN python -m pip install --upgrade pip \ + && python -m pip install -r requirements.txt \ + && python -m pip install -r requirements-api.txt + +COPY . . + +RUN mkdir -p /db + +EXPOSE 8080 +CMD ["python", "-m", "webapp.api"] diff --git a/docker-compose.yaml b/docker-compose.yaml index 7432aa9..3dd4ddd 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -11,5 +11,32 @@ services: PENIS_DB_PATH: /db/penis_stats.sqlite3 CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3 POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json + BET_DB_PATH: /db/bets.sqlite3 init: true restart: unless-stopped + + webapp-api: + build: + context: . + dockerfile: Dockerfile.api + volumes: + - ./db:/db + env_file: + - .env + environment: + TZ: Europe/Moscow + PENIS_DB_PATH: /db/penis_stats.sqlite3 + CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3 + POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json + BET_DB_PATH: /db/bets.sqlite3 + API_PORT: "8080" + restart: unless-stopped + + webapp-frontend: + build: + context: ./webapp/frontend + ports: + - "3000:80" + depends_on: + - webapp-api + restart: unless-stopped diff --git a/main.py b/main.py index 7ee496c..7c8c1a0 100644 --- a/main.py +++ b/main.py @@ -14,7 +14,8 @@ from aiogram.filters import Command, CommandStart from aiogram.enums import ParseMode from aiogram.types import ( BufferedInputFile, Message, BotCommand, FSInputFile, - BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats + BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats, + WebAppInfo, InlineKeyboardMarkup, InlineKeyboardButton ) from aiogram.client.default import DefaultBotProperties @@ -948,7 +949,14 @@ async def handle_help(message: Message): await message.answer(config.get_hint_text(templates_list_text()), parse_mode=None) async def handle_start(message: Message): - await message.answer("Соси") + webapp_url = os.getenv("WEBAPP_URL", "") + if webapp_url: + kb = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎮 Открыть Mini App", web_app=WebAppInfo(url=webapp_url))] + ]) + await message.answer("Йо, кент! Жми кнопку 👇", reply_markup=kb) + else: + await message.answer("Соси") async def handle_schedule_cmd(message: Message): now = datetime.now(ZoneInfo(config.DEFAULT_TZ)) diff --git a/webapp/__init__.py b/webapp/__init__.py new file mode 100644 index 0000000..f62e193 --- /dev/null +++ b/webapp/__init__.py @@ -0,0 +1 @@ +# webapp package diff --git a/webapp/api.py b/webapp/api.py new file mode 100644 index 0000000..7ef872c --- /dev/null +++ b/webapp/api.py @@ -0,0 +1,394 @@ +""" +FenyaBot Mini App — FastAPI Backend. + +REST API для Telegram Mini App. Использует те же SQLite базы и +бизнес-логику что и основной бот. +""" + +import asyncio +import logging +import os +import sys +from contextlib import asynccontextmanager +from typing import Annotated + +from fastapi import FastAPI, Depends, Header, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +# Добавляем корень проекта в sys.path чтобы импортировать модули бота +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import config +from games.casino import play_casino, get_user_length, spin_slots, MULTIPLIERS, update_user_length +from games.betting import ( + fetch_matches, + place_bet, + get_user_bets as _get_user_bets_raw, + get_user_bet_history as _get_user_bet_history_raw, +) +from webapp.auth import validate_init_data + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +# ──────────────────── Auth dependency ──────────────────── + +async def get_current_user(x_telegram_init_data: Annotated[str, Header()] = "") -> dict: + """Извлекает и валидирует пользователя из заголовка X-Telegram-Init-Data.""" + user = validate_init_data(x_telegram_init_data) + if user is None: + raise HTTPException(status_code=401, detail="Invalid Telegram auth") + return user + + +# ──────────────────── Pydantic models ──────────────────── + +class BetRequest(BaseModel): + league: str + match_index: int + outcome: str # "1", "X", "2" + amount: float + +class CasinoRequest(BaseModel): + bet: float + +class TalkRequest(BaseModel): + text: str + +class PenisResult(BaseModel): + success: bool + message: str + length: float | None = None + +class ProfileResponse(BaseModel): + user_id: int + first_name: str + username: str + length: float | None + active_bets: int + +class MatchResponse(BaseModel): + index: int + home: str + away: str + commence: str + odds_home: float | None + odds_draw: float | None + odds_away: float | None + +class LeagueInfo(BaseModel): + alias: str + name: str + sport_key: str + +class CasinoResult(BaseModel): + reels: list[str] + matches: int + multiplier: float + delta: float + new_length: float + won: bool + +class SchedulePair(BaseModel): + pair: int | None + title: str + time_start: str | None = None + time_end: str | None = None + + +# ──────────────────── Lifespan ──────────────────── + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("Mini App API started") + yield + logger.info("Mini App API stopped") + + +# ──────────────────── App ──────────────────── + +app = FastAPI( + title="FenyaBot Mini App API", + version="1.0.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# ──────────────────── Endpoints ──────────────────── + +# --- Профиль --- + +@app.get("/api/profile") +async def get_profile(user: dict = Depends(get_current_user)) -> ProfileResponse: + """Профиль пользователя: баланс и количество активных ставок.""" + user_id = user["user_id"] + length = await asyncio.to_thread(get_user_length, user_id) + + # Считаем активные ставки + import sqlite3 + active_bets = 0 + try: + with sqlite3.connect(config.BET_DB_PATH) as conn: + row = conn.execute( + "SELECT COUNT(*) FROM bets WHERE user_id = ? AND status = 'pending'", + (user_id,), + ).fetchone() + active_bets = row[0] if row else 0 + except Exception: + pass + + return ProfileResponse( + user_id=user_id, + first_name=user["first_name"], + username=user["username"], + length=length, + active_bets=active_bets, + ) + + +# --- Лиги --- + +@app.get("/api/leagues") +async def get_leagues() -> list[LeagueInfo]: + """Список доступных лиг для ставок.""" + labels = { + "epl": "⚽ EPL (Англия)", + "ucl": "⚽ Champions League", + "laliga": "⚽ La Liga (Испания)", + "bundesliga": "⚽ Bundesliga (Германия)", + "seriea": "⚽ Serie A (Италия)", + "ligue1": "⚽ Ligue 1 (Франция)", + "europa": "⚽ Europa League", + } + return [ + LeagueInfo(alias=alias, name=labels.get(alias, alias), sport_key=key) + for alias, key in config.ODDS_SPORTS.items() + ] + + +# --- Матчи --- + +@app.get("/api/matches/{league}") +async def get_matches(league: str) -> list[MatchResponse]: + """Матчи для конкретной лиги.""" + if league not in config.ODDS_SPORTS: + raise HTTPException(status_code=404, detail="League not found") + + sport_key = config.ODDS_SPORTS[league] + matches = await fetch_matches(sport_key) + + result = [] + for i, m in enumerate(matches): + # Извлекаем коэффициенты + odds_home = odds_draw = odds_away = None + bookmakers = m.get("bookmakers", []) + if bookmakers: + markets = bookmakers[0].get("markets", []) + for market in markets: + if market.get("key") == "h2h": + outcomes = market.get("outcomes", []) + for o in outcomes: + name = o.get("name", "") + price = o.get("price", 0) + if name == m.get("home_team"): + odds_home = price + elif name == m.get("away_team"): + odds_away = price + elif name == "Draw": + odds_draw = price + + result.append(MatchResponse( + index=i + 1, + home=m.get("home_team", "?"), + away=m.get("away_team", "?"), + commence=m.get("commence_time", ""), + odds_home=odds_home, + odds_draw=odds_draw, + odds_away=odds_away, + )) + + return result + + +# --- Ставки --- + +@app.post("/api/bet") +async def create_bet( + req: BetRequest, + user: dict = Depends(get_current_user), +): + """Разместить ставку на матч.""" + user_id = user["user_id"] + display_name = user["username"] or user["first_name"] + result = await place_bet(user_id, display_name, req.league, req.match_index, req.outcome, req.amount) + return {"message": result} + + +@app.get("/api/mybets") +async def get_my_bets(user: dict = Depends(get_current_user)): + """Активные ставки пользователя.""" + user_id = user["user_id"] + result = await _get_user_bets_raw(user_id) + return {"text": result} + + +@app.get("/api/bet_history") +async def get_bet_history(user: dict = Depends(get_current_user)): + """История ставок за месяц + статистика.""" + user_id = user["user_id"] + result = await _get_user_bet_history_raw(user_id) + return {"text": result} + + +# --- Казино --- + +@app.post("/api/casino") +async def play_casino_api( + req: CasinoRequest, + user: dict = Depends(get_current_user), +) -> CasinoResult: + """Играть в казино (слоты).""" + import random + + user_id = user["user_id"] + + current = await asyncio.to_thread(get_user_length, user_id) + if current is None: + raise HTTPException(status_code=400, detail="Сначала крутни /penis") + if current <= 0: + raise HTTPException(status_code=400, detail="Баланс <= 0") + if req.bet > config.CASINO_MAX_BET: + raise HTTPException(status_code=400, detail=f"Макс ставка: {config.CASINO_MAX_BET} см") + if req.bet <= 0: + raise HTTPException(status_code=400, detail="Ставка > 0") + if req.bet > current: + raise HTTPException(status_code=400, detail=f"У тебя {current:.1f} см") + + reels, match_count = await asyncio.to_thread(spin_slots) + + if match_count >= 2: + min_mult, max_mult = MULTIPLIERS[match_count] + multiplier = round(random.uniform(min_mult, max_mult), 1) + winnings = round(req.bet * multiplier, 1) + new_length = await asyncio.to_thread(update_user_length, user_id, winnings) + return CasinoResult( + reels=reels, matches=match_count, multiplier=multiplier, + delta=winnings, new_length=new_length or current, won=True, + ) + else: + new_length = await asyncio.to_thread(update_user_length, user_id, -req.bet) + return CasinoResult( + reels=reels, matches=0, multiplier=0, + delta=-req.bet, new_length=new_length or current, won=False, + ) + + +# --- Penis Game --- + +@app.post("/api/penis") +async def play_penis_api(user: dict = Depends(get_current_user)) -> PenisResult: + """Крутить penis (раз в 24 часа).""" + # Импортируем из main.py + from main import play_penis, format_penis_user_name + + user_id = user["user_id"] + display_name = user["username"] or user["first_name"] or f"user_{user_id}" + success, message = await asyncio.to_thread(play_penis, user_id, display_name) + length = await asyncio.to_thread(get_user_length, user_id) + return PenisResult(success=success, message=message, length=length) + + +# --- Топ --- + +@app.get("/api/top") +async def get_top(): + """Лидерборд по размеру.""" + from main import build_penis_top + result = await asyncio.to_thread(build_penis_top) + return {"text": result} + + +# --- Расписание --- + +@app.get("/api/schedule") +async def get_schedule(day: int | None = None) -> list[SchedulePair]: + """Расписание пар. day = 0-6 (пн-вс), None = сегодня.""" + from datetime import datetime + from zoneinfo import ZoneInfo + + if day is None: + day = datetime.now(ZoneInfo(config.DEFAULT_TZ)).weekday() + + # Определяем неделю (верхняя/нижняя) + now = datetime.now(ZoneInfo(config.DEFAULT_TZ)) + iso_week = now.isocalendar()[1] + if config.UPPER_WEEK_IS_ODD: + week_type = "upper" if iso_week % 2 == 1 else "lower" + else: + week_type = "upper" if iso_week % 2 == 0 else "lower" + + lessons = config.LESSON_SCHEDULE.get(week_type, {}).get(day, []) + + result = [] + for lesson in lessons: + pair_num = lesson.get("pair") + time_start = time_end = None + if pair_num and pair_num in config.PAIR_TIMES: + start, end = config.PAIR_TIMES[pair_num] + time_start = f"{start.hour:02d}:{start.minute:02d}" + time_end = f"{end.hour:02d}:{end.minute:02d}" + + result.append(SchedulePair( + pair=pair_num, + title=lesson.get("title", ""), + time_start=time_start, + time_end=time_end, + )) + + return result + + +# --- ИИ Чат --- + +@app.post("/api/talk") +async def talk_api( + req: TalkRequest, + user: dict = Depends(get_current_user), +): + """Отправить сообщение ИИ.""" + from AI.talk_handler import _generate_response, push_message + + user_id = user["user_id"] + user_name = user["first_name"] or "кент" + + # Используем user_id как chat_id для личных сообщений в webapp + chat_id = user_id + + try: + response = await _generate_response(chat_id, req.text, user_name) + if not response: + response = "Братуха, чёт базар не клеится." + await asyncio.to_thread(push_message, chat_id, "user", user_name, req.text) + await asyncio.to_thread(push_message, chat_id, "assistant", "бот", response) + return {"response": response} + except Exception: + logger.exception("Talk API failed") + raise HTTPException(status_code=500, detail="LLM error") + + +# ──────────────────── Entry point ──────────────────── + +if __name__ == "__main__": + import uvicorn + port = int(os.getenv("API_PORT", "8080")) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/webapp/auth.py b/webapp/auth.py new file mode 100644 index 0000000..8b0c43a --- /dev/null +++ b/webapp/auth.py @@ -0,0 +1,82 @@ +""" +Telegram Mini App — валидация initData. + +Telegram передаёт подписанные данные при открытии WebApp. +Мы проверяем HMAC-SHA256 подпись через BOT_TOKEN, +чтобы убедиться что запрос пришёл от реального пользователя. + +Документация: https://core.telegram.org/bots/webapps#validating-data +""" + +import hashlib +import hmac +import json +import os +import time +from urllib.parse import parse_qs + +BOT_TOKEN = os.getenv("BOT_TOKEN", "") + + +def validate_init_data(init_data: str, max_age_seconds: int = 86400) -> dict | None: + """ + Валидирует initData из Telegram WebApp. + + Возвращает dict с данными пользователя при успехе, None при ошибке. + """ + if not init_data or not BOT_TOKEN: + return None + + try: + parsed = parse_qs(init_data, keep_blank_values=True) + + # Извлекаем hash + received_hash = parsed.get("hash", [None])[0] + if not received_hash: + return None + + # Собираем data-check-string (все поля кроме hash, отсортированные) + data_pairs = [] + for key, values in parsed.items(): + if key == "hash": + continue + data_pairs.append(f"{key}={values[0]}") + data_pairs.sort() + data_check_string = "\n".join(data_pairs) + + # Вычисляем секретный ключ + secret_key = hmac.new( + b"WebAppData", BOT_TOKEN.encode("utf-8"), hashlib.sha256 + ).digest() + + # Вычисляем HMAC + computed_hash = hmac.new( + secret_key, data_check_string.encode("utf-8"), hashlib.sha256 + ).hexdigest() + + if not hmac.compare_digest(computed_hash, received_hash): + return None + + # Проверяем auth_date (не старше max_age_seconds) + auth_date_str = parsed.get("auth_date", [None])[0] + if auth_date_str: + auth_date = int(auth_date_str) + if time.time() - auth_date > max_age_seconds: + return None + + # Парсим user + user_str = parsed.get("user", [None])[0] + if not user_str: + return None + + user_data = json.loads(user_str) + return { + "user_id": user_data.get("id"), + "first_name": user_data.get("first_name", ""), + "last_name": user_data.get("last_name", ""), + "username": user_data.get("username", ""), + "language_code": user_data.get("language_code", "ru"), + } + + except Exception: + return None diff --git a/webapp/frontend/Dockerfile b/webapp/frontend/Dockerfile new file mode 100644 index 0000000..01bb247 --- /dev/null +++ b/webapp/frontend/Dockerfile @@ -0,0 +1,4 @@ +FROM nginx:alpine +COPY . /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/webapp/frontend/css/style.css b/webapp/frontend/css/style.css new file mode 100644 index 0000000..c227cd1 --- /dev/null +++ b/webapp/frontend/css/style.css @@ -0,0 +1,873 @@ +/* ═══════════════════════════════════════════ + FenyaBot Mini App — Design System + Theme: Blue-Black Glassmorphism + ═══════════════════════════════════════════ */ + +:root { + --bg-primary: #060a14; + --bg-secondary: #0c1222; + --bg-card: rgba(12, 22, 45, 0.75); + --bg-card-hover: rgba(18, 32, 60, 0.85); + --bg-nav: rgba(8, 14, 30, 0.92); + + --blue-500: #3b82f6; + --blue-400: #60a5fa; + --blue-300: #93c5fd; + --blue-600: #2563eb; + --blue-700: #1d4ed8; + --blue-900: #1e3a8a; + + --green-400: #4ade80; + --green-500: #22c55e; + --red-400: #f87171; + --red-500: #ef4444; + --yellow-400: #facc15; + --orange-400: #fb923c; + + --text-primary: #e2e8f0; + --text-secondary: #94a3b8; + --text-muted: #64748b; + + --gradient-primary: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%); + --gradient-accent: linear-gradient(135deg, #2563eb 0%, #60a5fa 100%); + --gradient-card: linear-gradient(145deg, rgba(15,25,50,0.8) 0%, rgba(8,16,35,0.9) 100%); + --gradient-casino: linear-gradient(135deg, #7c3aed 0%, #3b82f6 100%); + + --border-color: rgba(59, 130, 246, 0.15); + --border-glow: rgba(59, 130, 246, 0.3); + + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --radius-full: 9999px; + + --shadow-card: 0 4px 24px rgba(0, 0, 0, 0.4), 0 0 1px rgba(59, 130, 246, 0.1); + --shadow-glow: 0 0 20px rgba(59, 130, 246, 0.15); + --shadow-btn: 0 4px 16px rgba(59, 130, 246, 0.3); + + --nav-height: 64px; + --safe-bottom: env(safe-area-inset-bottom, 0px); +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html, body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; +} + +body::before { + content: ''; + position: fixed; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: radial-gradient(circle at 30% 20%, rgba(30, 58, 138, 0.15) 0%, transparent 50%), + radial-gradient(circle at 70% 80%, rgba(59, 130, 246, 0.08) 0%, transparent 40%); + z-index: -1; + pointer-events: none; +} + +#app { + max-width: 480px; + margin: 0 auto; + min-height: 100vh; + position: relative; +} + +#page-container { + padding: 16px 16px calc(var(--nav-height) + var(--safe-bottom) + 16px); + min-height: 100vh; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +/* ── Bottom Nav ── */ + +#bottom-nav { + position: fixed; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 100%; + max-width: 480px; + display: flex; + justify-content: space-around; + align-items: center; + height: var(--nav-height); + padding-bottom: var(--safe-bottom); + background: var(--bg-nav); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-top: 1px solid var(--border-color); + z-index: 100; +} + +.nav-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + padding: 6px 0; + transition: all 0.2s ease; + -webkit-tap-highlight-color: transparent; + flex: 1; +} + +.nav-btn.active { + color: var(--blue-400); +} + +.nav-btn:active { + transform: scale(0.92); +} + +.nav-icon { + font-size: 22px; + line-height: 1; +} + +.nav-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; +} + +/* ── Cards ── */ + +.card { + background: var(--gradient-card); + backdrop-filter: blur(12px); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + padding: 16px; + margin-bottom: 12px; + box-shadow: var(--shadow-card); + transition: transform 0.2s ease, box-shadow 0.2s ease; + animation: slideUp 0.4s ease backwards; +} + +.card:active { + transform: scale(0.98); +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.card-title { + font-size: 16px; + font-weight: 700; + color: var(--text-primary); +} + +.card-subtitle { + font-size: 12px; + color: var(--text-secondary); +} + +/* ── Profile Card ── */ + +.profile-card { + background: var(--gradient-primary); + border: none; + padding: 24px; + text-align: center; + position: relative; + overflow: hidden; +} + +.profile-card::after { + content: ''; + position: absolute; + top: -50%; + right: -50%; + width: 100%; + height: 100%; + background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); + pointer-events: none; +} + +.profile-avatar { + width: 64px; + height: 64px; + border-radius: var(--radius-full); + background: rgba(255,255,255,0.15); + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + margin: 0 auto 12px; + border: 2px solid rgba(255,255,255,0.2); +} + +.profile-name { + font-size: 20px; + font-weight: 800; + margin-bottom: 4px; +} + +.profile-balance { + font-size: 32px; + font-weight: 800; + margin: 12px 0 4px; + text-shadow: 0 2px 8px rgba(0,0,0,0.3); +} + +.profile-balance-label { + font-size: 13px; + opacity: 0.7; + font-weight: 500; +} + +/* ── Stat Grid ── */ + +.stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-bottom: 12px; +} + +.stat-item { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 14px; + text-align: center; +} + +.stat-value { + font-size: 22px; + font-weight: 800; + color: var(--blue-400); +} + +.stat-label { + font-size: 11px; + color: var(--text-secondary); + margin-top: 4px; + font-weight: 500; +} + +/* ── Buttons ── */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 12px 24px; + border-radius: var(--radius-md); + border: none; + font-family: 'Inter', sans-serif; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + -webkit-tap-highlight-color: transparent; + width: 100%; +} + +.btn:active { + transform: scale(0.96); +} + +.btn-primary { + background: var(--gradient-accent); + color: white; + box-shadow: var(--shadow-btn); +} + +.btn-primary:hover { + box-shadow: 0 6px 24px rgba(59, 130, 246, 0.4); +} + +.btn-secondary { + background: rgba(59, 130, 246, 0.12); + color: var(--blue-400); + border: 1px solid var(--border-color); +} + +.btn-danger { + background: rgba(239, 68, 68, 0.15); + color: var(--red-400); + border: 1px solid rgba(239, 68, 68, 0.2); +} + +.btn-sm { + padding: 8px 16px; + font-size: 12px; + border-radius: var(--radius-sm); +} + +.btn-lg { + padding: 16px 32px; + font-size: 16px; + border-radius: var(--radius-lg); +} + +/* ── Quick Actions ── */ + +.quick-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.quick-action { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 16px 12px; + text-align: center; + cursor: pointer; + transition: all 0.2s ease; + color: var(--text-primary); + font-family: 'Inter', sans-serif; +} + +.quick-action:active { + transform: scale(0.95); + background: var(--bg-card-hover); +} + +.quick-action-icon { + font-size: 28px; + margin-bottom: 6px; +} + +.quick-action-label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); +} + +/* ── Match Card ── */ + +.match-card { + padding: 14px; +} + +.match-teams { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.match-team { + flex: 1; + text-align: center; + font-size: 13px; + font-weight: 600; +} + +.match-vs { + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + padding: 0 8px; +} + +.match-time { + text-align: center; + font-size: 11px; + color: var(--text-muted); + margin-bottom: 10px; +} + +.match-odds { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 6px; +} + +.odds-btn { + background: rgba(59, 130, 246, 0.08); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + padding: 8px; + text-align: center; + cursor: pointer; + transition: all 0.15s ease; + color: var(--text-primary); + font-family: 'Inter', sans-serif; +} + +.odds-btn:hover, .odds-btn.selected { + background: rgba(59, 130, 246, 0.2); + border-color: var(--blue-500); + box-shadow: 0 0 8px rgba(59, 130, 246, 0.2); +} + +.odds-label { + font-size: 10px; + color: var(--text-muted); + display: block; +} + +.odds-value { + font-size: 15px; + font-weight: 700; + color: var(--blue-400); + display: block; + margin-top: 2px; +} + +/* ── Casino / Slots ── */ + +.slots-display { + display: flex; + justify-content: center; + gap: 12px; + margin: 24px 0; +} + +.slot-reel { + width: 72px; + height: 72px; + background: var(--bg-secondary); + border: 2px solid var(--border-color); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 36px; + transition: all 0.3s ease; +} + +.slot-reel.spinning { + animation: spin 0.3s linear infinite; +} + +.slot-reel.won { + border-color: var(--green-400); + box-shadow: 0 0 16px rgba(74, 222, 128, 0.3); +} + +.casino-bet-input { + display: flex; + align-items: center; + gap: 10px; + margin: 16px 0; +} + +.casino-bet-input input { + flex: 1; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 12px 16px; + color: var(--text-primary); + font-family: 'Inter', sans-serif; + font-size: 16px; + font-weight: 600; + text-align: center; + outline: none; + transition: border-color 0.2s; +} + +.casino-bet-input input:focus { + border-color: var(--blue-500); +} + +/* ── Schedule ── */ + +.schedule-day-tabs { + display: flex; + gap: 6px; + overflow-x: auto; + padding-bottom: 8px; + margin-bottom: 12px; + scrollbar-width: none; +} + +.schedule-day-tabs::-webkit-scrollbar { display: none; } + +.day-tab { + flex-shrink: 0; + padding: 8px 14px; + border-radius: var(--radius-full); + background: var(--bg-card); + border: 1px solid var(--border-color); + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + font-family: 'Inter', sans-serif; +} + +.day-tab.active { + background: var(--gradient-accent); + color: white; + border-color: transparent; +} + +.lesson-item { + display: flex; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid rgba(59, 130, 246, 0.06); +} + +.lesson-item:last-child { + border-bottom: none; +} + +.lesson-number { + width: 36px; + height: 36px; + border-radius: var(--radius-sm); + background: rgba(59, 130, 246, 0.1); + color: var(--blue-400); + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 700; + flex-shrink: 0; +} + +.lesson-info { + flex: 1; +} + +.lesson-title { + font-size: 13px; + font-weight: 600; + margin-bottom: 2px; +} + +.lesson-time { + font-size: 11px; + color: var(--text-muted); +} + +/* ── Chat ── */ + +.chat-messages { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 72px; + min-height: 50vh; +} + +.chat-bubble { + max-width: 85%; + padding: 10px 14px; + border-radius: var(--radius-lg); + font-size: 14px; + line-height: 1.4; + animation: slideUp 0.2s ease; +} + +.chat-bubble.user { + align-self: flex-end; + background: var(--gradient-accent); + color: white; + border-bottom-right-radius: 4px; +} + +.chat-bubble.bot { + align-self: flex-start; + background: var(--bg-card); + border: 1px solid var(--border-color); + color: var(--text-primary); + border-bottom-left-radius: 4px; +} + +.chat-input-bar { + position: fixed; + bottom: var(--nav-height); + left: 50%; + transform: translateX(-50%); + width: 100%; + max-width: 480px; + display: flex; + gap: 8px; + padding: 8px 16px; + background: var(--bg-nav); + backdrop-filter: blur(20px); + border-top: 1px solid var(--border-color); +} + +.chat-input-bar input { + flex: 1; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-full); + padding: 10px 16px; + color: var(--text-primary); + font-family: 'Inter', sans-serif; + font-size: 14px; + outline: none; +} + +.chat-input-bar input:focus { + border-color: var(--blue-500); +} + +.chat-send-btn { + width: 40px; + height: 40px; + border-radius: var(--radius-full); + background: var(--gradient-accent); + border: none; + color: white; + font-size: 18px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.15s; +} + +.chat-send-btn:active { + transform: scale(0.9); +} + +/* ── League List ── */ + +.league-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.league-item { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s; + font-family: 'Inter', sans-serif; + color: var(--text-primary); +} + +.league-item:active { + transform: scale(0.97); + background: var(--bg-card-hover); +} + +.league-icon { + font-size: 24px; +} + +.league-name { + font-size: 14px; + font-weight: 600; +} + +.league-arrow { + margin-left: auto; + color: var(--text-muted); +} + +/* ── Bet Slip ── */ + +.bet-slip { + position: fixed; + bottom: var(--nav-height); + left: 50%; + transform: translateX(-50%) translateY(100%); + width: 100%; + max-width: 480px; + background: var(--bg-nav); + backdrop-filter: blur(20px); + border-top: 1px solid var(--border-glow); + padding: 16px; + transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); + z-index: 50; +} + +.bet-slip.visible { + transform: translateX(-50%) translateY(0); +} + +.bet-slip-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.bet-input { + width: 100%; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + padding: 12px 16px; + color: var(--text-primary); + font-family: 'Inter', sans-serif; + font-size: 16px; + font-weight: 600; + text-align: center; + outline: none; + margin-bottom: 10px; +} + +.bet-input:focus { + border-color: var(--blue-500); +} + +/* ── Loading skeleton ── */ + +.skeleton { + background: linear-gradient(90deg, var(--bg-card) 25%, var(--bg-card-hover) 50%, var(--bg-card) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: var(--radius-md); +} + +.skeleton-line { + height: 14px; + margin-bottom: 8px; + border-radius: 4px; +} + +.skeleton-circle { + width: 64px; + height: 64px; + border-radius: var(--radius-full); + margin: 0 auto 12px; +} + +/* ── Section header ── */ + +.section-header { + font-size: 18px; + font-weight: 700; + margin-bottom: 12px; + padding-left: 4px; +} + +/* ── Badge ── */ + +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: var(--radius-full); + font-size: 11px; + font-weight: 600; +} + +.badge-green { + background: rgba(34, 197, 94, 0.15); + color: var(--green-400); +} + +.badge-red { + background: rgba(239, 68, 68, 0.15); + color: var(--red-400); +} + +.badge-blue { + background: rgba(59, 130, 246, 0.15); + color: var(--blue-400); +} + +/* ── Result popup ── */ + +.result-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + animation: fadeIn 0.2s ease; +} + +.result-popup { + background: var(--bg-secondary); + border: 1px solid var(--border-glow); + border-radius: var(--radius-xl); + padding: 32px 24px; + text-align: center; + max-width: 320px; + width: 90%; + animation: slideUp 0.3s ease; +} + +.result-icon { + font-size: 48px; + margin-bottom: 12px; +} + +.result-title { + font-size: 20px; + font-weight: 800; + margin-bottom: 8px; +} + +.result-desc { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 20px; + line-height: 1.5; +} + +/* ── Empty state ── */ + +.empty-state { + text-align: center; + padding: 48px 16px; + color: var(--text-muted); +} + +.empty-state-icon { + font-size: 48px; + margin-bottom: 12px; +} + +.empty-state-text { + font-size: 14px; + line-height: 1.5; +} diff --git a/webapp/frontend/index.html b/webapp/frontend/index.html new file mode 100644 index 0000000..b88bcc0 --- /dev/null +++ b/webapp/frontend/index.html @@ -0,0 +1,42 @@ + + +
+ + + +