patch_Oreshnik #23
14 changed files with 2078 additions and 3 deletions
2
.env
2
.env
|
|
@ -1,3 +1,3 @@
|
||||||
BOT_TOKEN=8156642467:AAEalekr-33lFS3tTTkXIEiaawfDzR6lBJc
|
BOT_TOKEN=8156642467:AAEalekr-33lFS3tTTkXIEiaawfDzR6lBJc
|
||||||
TZ=Europe/Moscow
|
TZ=Europe/Moscow
|
||||||
ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923
|
ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923
|
||||||
18
Dockerfile.api
Normal file
18
Dockerfile.api
Normal file
|
|
@ -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"]
|
||||||
|
|
@ -11,5 +11,32 @@ services:
|
||||||
PENIS_DB_PATH: /db/penis_stats.sqlite3
|
PENIS_DB_PATH: /db/penis_stats.sqlite3
|
||||||
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
|
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
|
||||||
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
|
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
|
||||||
|
BET_DB_PATH: /db/bets.sqlite3
|
||||||
init: true
|
init: true
|
||||||
restart: unless-stopped
|
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
|
||||||
|
|
|
||||||
12
main.py
12
main.py
|
|
@ -14,7 +14,8 @@ from aiogram.filters import Command, CommandStart
|
||||||
from aiogram.enums import ParseMode
|
from aiogram.enums import ParseMode
|
||||||
from aiogram.types import (
|
from aiogram.types import (
|
||||||
BufferedInputFile, Message, BotCommand, FSInputFile,
|
BufferedInputFile, Message, BotCommand, FSInputFile,
|
||||||
BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats
|
BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats,
|
||||||
|
WebAppInfo, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
)
|
)
|
||||||
from aiogram.client.default import DefaultBotProperties
|
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)
|
await message.answer(config.get_hint_text(templates_list_text()), parse_mode=None)
|
||||||
|
|
||||||
async def handle_start(message: Message):
|
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):
|
async def handle_schedule_cmd(message: Message):
|
||||||
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
|
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
|
||||||
|
|
|
||||||
1
webapp/__init__.py
Normal file
1
webapp/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# webapp package
|
||||||
394
webapp/api.py
Normal file
394
webapp/api.py
Normal file
|
|
@ -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)
|
||||||
82
webapp/auth.py
Normal file
82
webapp/auth.py
Normal file
|
|
@ -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
|
||||||
4
webapp/frontend/Dockerfile
Normal file
4
webapp/frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY . /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
873
webapp/frontend/css/style.css
Normal file
873
webapp/frontend/css/style.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
42
webapp/frontend/index.html
Normal file
42
webapp/frontend/index.html
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||||
|
<meta name="theme-color" content="#0a0e1a">
|
||||||
|
<title>FenyaBot</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div id="page-container"></div>
|
||||||
|
<nav id="bottom-nav">
|
||||||
|
<button class="nav-btn active" data-page="home">
|
||||||
|
<span class="nav-icon">🏠</span>
|
||||||
|
<span class="nav-label">Главная</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn" data-page="betting">
|
||||||
|
<span class="nav-icon">⚽</span>
|
||||||
|
<span class="nav-label">Ставки</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn" data-page="casino">
|
||||||
|
<span class="nav-icon">🎰</span>
|
||||||
|
<span class="nav-label">Казино</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn" data-page="schedule">
|
||||||
|
<span class="nav-icon">📅</span>
|
||||||
|
<span class="nav-label">Пары</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn" data-page="chat">
|
||||||
|
<span class="nav-icon">💬</span>
|
||||||
|
<span class="nav-label">Чат</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
44
webapp/frontend/js/api.js
Normal file
44
webapp/frontend/js/api.js
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
/**
|
||||||
|
* FenyaBot Mini App — API wrapper
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = window.location.origin;
|
||||||
|
|
||||||
|
function getInitData() {
|
||||||
|
if (window.Telegram?.WebApp?.initData) {
|
||||||
|
return window.Telegram.WebApp.initData;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const { method = 'GET', body = null } = options;
|
||||||
|
const headers = {
|
||||||
|
'X-Telegram-Init-Data': getInitData(),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = { method, headers };
|
||||||
|
if (body) config.body = JSON.stringify(body);
|
||||||
|
|
||||||
|
const resp = await fetch(`${API_BASE}${path}`, config);
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const API = {
|
||||||
|
getProfile: () => api('/api/profile'),
|
||||||
|
getLeagues: () => api('/api/leagues'),
|
||||||
|
getMatches: (league) => api(`/api/matches/${league}`),
|
||||||
|
placeBet: (data) => api('/api/bet', { method: 'POST', body: data }),
|
||||||
|
getMyBets: () => api('/api/mybets'),
|
||||||
|
getBetHistory: () => api('/api/bet_history'),
|
||||||
|
playCasino: (bet) => api('/api/casino', { method: 'POST', body: { bet } }),
|
||||||
|
playPenis: () => api('/api/penis', { method: 'POST' }),
|
||||||
|
getTop: () => api('/api/top'),
|
||||||
|
getSchedule: (day) => api(`/api/schedule${day != null ? `?day=${day}` : ''}`),
|
||||||
|
talk: (text) => api('/api/talk', { method: 'POST', body: { text } }),
|
||||||
|
};
|
||||||
554
webapp/frontend/js/app.js
Normal file
554
webapp/frontend/js/app.js
Normal file
|
|
@ -0,0 +1,554 @@
|
||||||
|
/**
|
||||||
|
* FenyaBot Mini App — Main Application
|
||||||
|
* SPA with vanilla JS routing and page modules
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { API } from './api.js';
|
||||||
|
|
||||||
|
// ── Telegram WebApp SDK ──
|
||||||
|
const tg = window.Telegram?.WebApp;
|
||||||
|
if (tg) {
|
||||||
|
tg.ready();
|
||||||
|
tg.expand();
|
||||||
|
tg.setHeaderColor('#060a14');
|
||||||
|
tg.setBackgroundColor('#060a14');
|
||||||
|
}
|
||||||
|
|
||||||
|
function haptic(type = 'impact') {
|
||||||
|
try {
|
||||||
|
if (type === 'impact') tg?.HapticFeedback?.impactOccurred('light');
|
||||||
|
else if (type === 'success') tg?.HapticFeedback?.notificationOccurred('success');
|
||||||
|
else if (type === 'error') tg?.HapticFeedback?.notificationOccurred('error');
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State ──
|
||||||
|
let currentPage = 'home';
|
||||||
|
let profileData = null;
|
||||||
|
let chatHistory = [];
|
||||||
|
let selectedBet = null; // { league, matchIndex, outcome, odds }
|
||||||
|
|
||||||
|
const container = document.getElementById('page-container');
|
||||||
|
const navBtns = document.querySelectorAll('.nav-btn');
|
||||||
|
|
||||||
|
// ── Router ──
|
||||||
|
function navigate(page, data = null) {
|
||||||
|
currentPage = page;
|
||||||
|
haptic();
|
||||||
|
navBtns.forEach(b => b.classList.toggle('active', b.dataset.page === page));
|
||||||
|
container.style.animation = 'none';
|
||||||
|
container.offsetHeight;
|
||||||
|
container.style.animation = 'fadeIn 0.25s ease';
|
||||||
|
|
||||||
|
switch (page) {
|
||||||
|
case 'home': renderHome(); break;
|
||||||
|
case 'betting': renderBetting(data); break;
|
||||||
|
case 'casino': renderCasino(); break;
|
||||||
|
case 'schedule': renderSchedule(); break;
|
||||||
|
case 'chat': renderChat(); break;
|
||||||
|
default: renderHome();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
navBtns.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => navigate(btn.dataset.page));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
function $(html) {
|
||||||
|
const t = document.createElement('template');
|
||||||
|
t.innerHTML = html.trim();
|
||||||
|
return t.content.firstChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showResult(icon, title, desc, btnText = 'OK') {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const overlay = $(`
|
||||||
|
<div class="result-overlay">
|
||||||
|
<div class="result-popup">
|
||||||
|
<div class="result-icon">${icon}</div>
|
||||||
|
<div class="result-title">${title}</div>
|
||||||
|
<div class="result-desc">${desc}</div>
|
||||||
|
<button class="btn btn-primary">${btnText}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
overlay.querySelector('.btn').onclick = () => { overlay.remove(); resolve(); };
|
||||||
|
overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(); } };
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function skeleton(lines = 3) {
|
||||||
|
return `<div class="card">${'<div class="skeleton skeleton-line" style="width:' + (60 + Math.random()*35) + '%"></div>'.repeat(lines)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// PAGE: HOME
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
|
||||||
|
async function renderHome() {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="profile-card card">
|
||||||
|
<div class="skeleton skeleton-circle"></div>
|
||||||
|
<div class="skeleton skeleton-line" style="width:40%;margin:0 auto"></div>
|
||||||
|
<div class="skeleton skeleton-line" style="width:60%;margin:8px auto"></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat-item"><div class="skeleton skeleton-line"></div></div>
|
||||||
|
<div class="stat-item"><div class="skeleton skeleton-line"></div></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
profileData = await API.getProfile();
|
||||||
|
const p = profileData;
|
||||||
|
const length = p.length != null ? p.length.toFixed(1) : '—';
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="profile-card card">
|
||||||
|
<div class="profile-avatar">👤</div>
|
||||||
|
<div class="profile-name">${p.first_name || p.username || 'Кент'}</div>
|
||||||
|
<div class="profile-balance">${length} см</div>
|
||||||
|
<div class="profile-balance-label">текущий размер</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">${p.active_bets}</div>
|
||||||
|
<div class="stat-label">Активных ставок</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item" id="penis-btn" style="cursor:pointer">
|
||||||
|
<div class="stat-value">🎲</div>
|
||||||
|
<div class="stat-label">Крутить penis</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header">Быстрые действия</div>
|
||||||
|
<div class="quick-actions">
|
||||||
|
<button class="quick-action" data-action="betting">
|
||||||
|
<div class="quick-action-icon">⚽</div>
|
||||||
|
<div class="quick-action-label">Ставки</div>
|
||||||
|
</button>
|
||||||
|
<button class="quick-action" data-action="casino">
|
||||||
|
<div class="quick-action-icon">🎰</div>
|
||||||
|
<div class="quick-action-label">Казино</div>
|
||||||
|
</button>
|
||||||
|
<button class="quick-action" data-action="top">
|
||||||
|
<div class="quick-action-icon">🏆</div>
|
||||||
|
<div class="quick-action-label">Топ</div>
|
||||||
|
</button>
|
||||||
|
<button class="quick-action" data-action="history">
|
||||||
|
<div class="quick-action-icon">📊</div>
|
||||||
|
<div class="quick-action-label">История</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Крутить penis
|
||||||
|
document.getElementById('penis-btn')?.addEventListener('click', async () => {
|
||||||
|
haptic();
|
||||||
|
try {
|
||||||
|
const res = await API.playPenis();
|
||||||
|
if (res.success) {
|
||||||
|
haptic('success');
|
||||||
|
await showResult('🎉', 'Успех!', res.message);
|
||||||
|
} else {
|
||||||
|
await showResult('⏳', 'Подожди', res.message);
|
||||||
|
}
|
||||||
|
renderHome();
|
||||||
|
} catch (e) {
|
||||||
|
haptic('error');
|
||||||
|
await showResult('❌', 'Ошибка', e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quick actions
|
||||||
|
container.querySelectorAll('.quick-action').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
if (action === 'betting') navigate('betting');
|
||||||
|
else if (action === 'casino') navigate('casino');
|
||||||
|
else if (action === 'top') showTop();
|
||||||
|
else if (action === 'history') showBetHistory();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">⚠️</div>
|
||||||
|
<div class="empty-state-text">Не удалось загрузить профиль<br>${e.message}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showTop() {
|
||||||
|
try {
|
||||||
|
const data = await API.getTop();
|
||||||
|
await showResult('🏆', 'Лидерборд', data.text.replace(/\n/g, '<br>'));
|
||||||
|
} catch (e) {
|
||||||
|
await showResult('❌', 'Ошибка', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showBetHistory() {
|
||||||
|
try {
|
||||||
|
const data = await API.getBetHistory();
|
||||||
|
await showResult('📊', 'История ставок', data.text.replace(/\n/g, '<br>'));
|
||||||
|
} catch (e) {
|
||||||
|
await showResult('❌', 'Ошибка', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// PAGE: BETTING
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
|
||||||
|
async function renderBetting(data) {
|
||||||
|
if (data?.league) {
|
||||||
|
return renderMatches(data.league, data.leagueName);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `<div class="section-header">⚽ Ставки на матчи</div>${skeleton(2)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const leagues = await API.getLeagues();
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="section-header">⚽ Выбери лигу</div>
|
||||||
|
<div class="league-list" id="league-list"></div>
|
||||||
|
`;
|
||||||
|
const list = document.getElementById('league-list');
|
||||||
|
leagues.forEach(l => {
|
||||||
|
const item = $(`
|
||||||
|
<div class="league-item">
|
||||||
|
<span class="league-icon">⚽</span>
|
||||||
|
<span class="league-name">${l.name}</span>
|
||||||
|
<span class="league-arrow">›</span>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
haptic();
|
||||||
|
navigate('betting', { league: l.alias, leagueName: l.name });
|
||||||
|
});
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = `<div class="empty-state"><div class="empty-state-icon">❌</div><div class="empty-state-text">${e.message}</div></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderMatches(league, leagueName) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="back-btn" style="width:auto;padding:8px 12px">← Назад</button>
|
||||||
|
<div class="section-header" style="margin:0">${leagueName}</div>
|
||||||
|
</div>
|
||||||
|
${skeleton(3)}
|
||||||
|
`;
|
||||||
|
document.getElementById('back-btn').onclick = () => navigate('betting');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const matches = await API.getMatches(league);
|
||||||
|
if (!matches.length) {
|
||||||
|
container.innerHTML += `<div class="empty-state"><div class="empty-state-icon">📭</div><div class="empty-state-text">Нет матчей в этой лиге</div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove skeleton
|
||||||
|
container.querySelectorAll('.card').forEach(c => c.remove());
|
||||||
|
|
||||||
|
matches.forEach((m, idx) => {
|
||||||
|
const date = m.commence ? new Date(m.commence).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '';
|
||||||
|
const card = $(`
|
||||||
|
<div class="card match-card" style="animation-delay:${idx * 0.05}s">
|
||||||
|
<div class="match-time">${date}</div>
|
||||||
|
<div class="match-teams">
|
||||||
|
<div class="match-team">${m.home}</div>
|
||||||
|
<div class="match-vs">VS</div>
|
||||||
|
<div class="match-team">${m.away}</div>
|
||||||
|
</div>
|
||||||
|
<div class="match-odds">
|
||||||
|
<button class="odds-btn" data-outcome="1" data-odds="${m.odds_home || 0}">
|
||||||
|
<span class="odds-label">П1</span>
|
||||||
|
<span class="odds-value">${m.odds_home?.toFixed(2) || '—'}</span>
|
||||||
|
</button>
|
||||||
|
<button class="odds-btn" data-outcome="X" data-odds="${m.odds_draw || 0}">
|
||||||
|
<span class="odds-label">X</span>
|
||||||
|
<span class="odds-value">${m.odds_draw?.toFixed(2) || '—'}</span>
|
||||||
|
</button>
|
||||||
|
<button class="odds-btn" data-outcome="2" data-odds="${m.odds_away || 0}">
|
||||||
|
<span class="odds-label">П2</span>
|
||||||
|
<span class="odds-value">${m.odds_away?.toFixed(2) || '—'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
card.querySelectorAll('.odds-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
haptic();
|
||||||
|
selectedBet = {
|
||||||
|
league,
|
||||||
|
matchIndex: m.index,
|
||||||
|
outcome: btn.dataset.outcome,
|
||||||
|
odds: parseFloat(btn.dataset.odds),
|
||||||
|
home: m.home,
|
||||||
|
away: m.away,
|
||||||
|
};
|
||||||
|
showBetSlip();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML += `<div class="empty-state"><div class="empty-state-icon">❌</div><div class="empty-state-text">${e.message}</div></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBetSlip() {
|
||||||
|
if (!selectedBet) return;
|
||||||
|
document.querySelector('.bet-slip')?.remove();
|
||||||
|
|
||||||
|
const outcomeLabel = { '1': `П1 (${selectedBet.home})`, 'X': 'Ничья', '2': `П2 (${selectedBet.away})` };
|
||||||
|
const slip = $(`
|
||||||
|
<div class="bet-slip visible">
|
||||||
|
<div class="bet-slip-header">
|
||||||
|
<span class="card-title">${outcomeLabel[selectedBet.outcome]}</span>
|
||||||
|
<span class="badge badge-blue">x${selectedBet.odds?.toFixed(2) || '?'}</span>
|
||||||
|
</div>
|
||||||
|
<input type="number" class="bet-input" placeholder="Ставка (см)" step="0.1" min="0.1" max="3" value="0.5">
|
||||||
|
<button class="btn btn-primary" id="place-bet-btn">Поставить</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
slip.querySelector('#place-bet-btn').addEventListener('click', async () => {
|
||||||
|
const amount = parseFloat(slip.querySelector('.bet-input').value);
|
||||||
|
if (!amount || amount <= 0) return;
|
||||||
|
try {
|
||||||
|
const res = await API.placeBet({
|
||||||
|
league: selectedBet.league,
|
||||||
|
match_index: selectedBet.matchIndex,
|
||||||
|
outcome: selectedBet.outcome,
|
||||||
|
amount,
|
||||||
|
});
|
||||||
|
haptic('success');
|
||||||
|
slip.remove();
|
||||||
|
await showResult('✅', 'Ставка принята!', res.message);
|
||||||
|
} catch (e) {
|
||||||
|
haptic('error');
|
||||||
|
await showResult('❌', 'Ошибка', e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(slip);
|
||||||
|
setTimeout(() => slip.classList.add('visible'), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// PAGE: CASINO
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
|
||||||
|
async function renderCasino() {
|
||||||
|
const balance = profileData?.length != null ? profileData.length.toFixed(1) : '?';
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="section-header">🎰 Казино</div>
|
||||||
|
<div class="card" style="text-align:center;padding:24px">
|
||||||
|
<div style="font-size:13px;color:var(--text-secondary);margin-bottom:4px">Баланс</div>
|
||||||
|
<div style="font-size:24px;font-weight:800;color:var(--blue-400)" id="casino-balance">${balance} см</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="text-align:center;padding:24px">
|
||||||
|
<div class="slots-display">
|
||||||
|
<div class="slot-reel" id="reel1">🎰</div>
|
||||||
|
<div class="slot-reel" id="reel2">🎰</div>
|
||||||
|
<div class="slot-reel" id="reel3">🎰</div>
|
||||||
|
</div>
|
||||||
|
<div class="casino-bet-input">
|
||||||
|
<input type="number" id="casino-bet" value="0.5" step="0.1" min="0.1" max="1" placeholder="Ставка">
|
||||||
|
<span style="color:var(--text-secondary);font-size:13px">см</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-lg" id="spin-btn">🎰 Крутить!</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('spin-btn').addEventListener('click', async () => {
|
||||||
|
const bet = parseFloat(document.getElementById('casino-bet').value);
|
||||||
|
if (!bet || bet <= 0) return;
|
||||||
|
|
||||||
|
haptic();
|
||||||
|
const btn = document.getElementById('spin-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '⏳ Крутим...';
|
||||||
|
|
||||||
|
// Анимация вращения
|
||||||
|
const symbols = ['🍒', '🍋', '🔔', '💎', '7️⃣', '🍀'];
|
||||||
|
const reels = [document.getElementById('reel1'), document.getElementById('reel2'), document.getElementById('reel3')];
|
||||||
|
reels.forEach(r => r.classList.add('spinning'));
|
||||||
|
|
||||||
|
const spinInterval = setInterval(() => {
|
||||||
|
reels.forEach(r => { r.textContent = symbols[Math.floor(Math.random() * symbols.length)]; });
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await API.playCasino(bet);
|
||||||
|
|
||||||
|
clearInterval(spinInterval);
|
||||||
|
|
||||||
|
// Останавливаем слоты по одному
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
reels[i].classList.remove('spinning');
|
||||||
|
reels[i].textContent = res.reels[i];
|
||||||
|
if (res.won) reels[i].classList.add('won');
|
||||||
|
haptic();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем баланс
|
||||||
|
document.getElementById('casino-balance').textContent = `${res.new_length.toFixed(1)} см`;
|
||||||
|
if (profileData) profileData.length = res.new_length;
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
|
||||||
|
if (res.won) {
|
||||||
|
haptic('success');
|
||||||
|
const jackpot = res.matches === 3;
|
||||||
|
await showResult(
|
||||||
|
jackpot ? '🎉' : '✅',
|
||||||
|
jackpot ? 'ДЖЕКПОТ!!!' : 'Выигрыш!',
|
||||||
|
`${jackpot ? 'Три одинаковых!' : 'Две совпали!'}\nx${res.multiplier}\n+${res.delta.toFixed(1)} см\nБаланс: ${res.new_length.toFixed(1)} см`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
haptic('error');
|
||||||
|
await showResult('❌', 'Мимо!', `−${Math.abs(res.delta).toFixed(1)} см\nБаланс: ${res.new_length.toFixed(1)} см`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
clearInterval(spinInterval);
|
||||||
|
reels.forEach(r => r.classList.remove('spinning'));
|
||||||
|
haptic('error');
|
||||||
|
await showResult('❌', 'Ошибка', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '🎰 Крутить!';
|
||||||
|
reels.forEach(r => r.classList.remove('won'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// PAGE: SCHEDULE
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
|
||||||
|
async function renderSchedule() {
|
||||||
|
const days = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||||
|
const today = new Date().getDay();
|
||||||
|
const todayIdx = today === 0 ? 6 : today - 1;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="section-header">📅 Расписание</div>
|
||||||
|
<div class="schedule-day-tabs" id="day-tabs">
|
||||||
|
${days.map((d, i) => `<button class="day-tab ${i === todayIdx ? 'active' : ''}" data-day="${i}">${d}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
<div id="lessons-container">${skeleton(4)}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function loadDay(dayIdx) {
|
||||||
|
const lc = document.getElementById('lessons-container');
|
||||||
|
lc.innerHTML = skeleton(3);
|
||||||
|
try {
|
||||||
|
const lessons = await API.getSchedule(dayIdx);
|
||||||
|
if (!lessons.length) {
|
||||||
|
lc.innerHTML = `<div class="empty-state"><div class="empty-state-icon">🎉</div><div class="empty-state-text">Нет пар!</div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lc.innerHTML = '<div class="card">' + lessons.map(l => `
|
||||||
|
<div class="lesson-item">
|
||||||
|
<div class="lesson-number">${l.pair || '—'}</div>
|
||||||
|
<div class="lesson-info">
|
||||||
|
<div class="lesson-title">${l.title}</div>
|
||||||
|
<div class="lesson-time">${l.time_start && l.time_end ? `${l.time_start} — ${l.time_end}` : ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('') + '</div>';
|
||||||
|
} catch (e) {
|
||||||
|
lc.innerHTML = `<div class="empty-state"><div class="empty-state-icon">❌</div><div class="empty-state-text">${e.message}</div></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('day-tabs').addEventListener('click', (e) => {
|
||||||
|
const tab = e.target.closest('.day-tab');
|
||||||
|
if (!tab) return;
|
||||||
|
haptic();
|
||||||
|
document.querySelectorAll('.day-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
loadDay(parseInt(tab.dataset.day));
|
||||||
|
});
|
||||||
|
|
||||||
|
loadDay(todayIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// PAGE: CHAT
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
|
||||||
|
function renderChat() {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="section-header">💬 ИИ на фене</div>
|
||||||
|
<div class="chat-messages" id="chat-messages">
|
||||||
|
${chatHistory.length === 0 ? `
|
||||||
|
<div class="chat-bubble bot">Йо, кент! Базарь, я тут. Пиши чё надо, братуха 🤙</div>
|
||||||
|
` : chatHistory.map(m => `<div class="chat-bubble ${m.role}">${m.text}</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
<div class="chat-input-bar">
|
||||||
|
<input type="text" id="chat-input" placeholder="Напиши сообщение..." autocomplete="off">
|
||||||
|
<button class="chat-send-btn" id="chat-send">➤</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input = document.getElementById('chat-input');
|
||||||
|
const sendBtn = document.getElementById('chat-send');
|
||||||
|
const messages = document.getElementById('chat-messages');
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
input.value = '';
|
||||||
|
haptic();
|
||||||
|
|
||||||
|
// Add user bubble
|
||||||
|
chatHistory.push({ role: 'user', text });
|
||||||
|
const userBubble = $(`<div class="chat-bubble user">${text}</div>`);
|
||||||
|
messages.appendChild(userBubble);
|
||||||
|
messages.scrollTop = messages.scrollHeight;
|
||||||
|
|
||||||
|
// Loading
|
||||||
|
const loading = $(`<div class="chat-bubble bot" id="chat-loading">💭 печатает...</div>`);
|
||||||
|
messages.appendChild(loading);
|
||||||
|
messages.scrollTop = messages.scrollHeight;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await API.talk(text);
|
||||||
|
loading.remove();
|
||||||
|
chatHistory.push({ role: 'bot', text: res.response });
|
||||||
|
const botBubble = $(`<div class="chat-bubble bot">${res.response}</div>`);
|
||||||
|
messages.appendChild(botBubble);
|
||||||
|
messages.scrollTop = messages.scrollHeight;
|
||||||
|
} catch (e) {
|
||||||
|
loading.remove();
|
||||||
|
const errBubble = $(`<div class="chat-bubble bot">Ошибка: ${e.message}</div>`);
|
||||||
|
messages.appendChild(errBubble);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendBtn.addEventListener('click', send);
|
||||||
|
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); });
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ──
|
||||||
|
navigate('home');
|
||||||
26
webapp/frontend/nginx.conf
Normal file
26
webapp/frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SPA fallback
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API requests to backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://webapp-api:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Telegram-Init-Data $http_x_telegram_init_data;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(css|js|png|jpg|svg|woff2?)$ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
2
webapp/requirements-api.txt
Normal file
2
webapp/requirements-api.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fastapi>=0.111.0
|
||||||
|
uvicorn[standard]>=0.29.0
|
||||||
Loading…
Reference in a new issue