1
0
Fork 0
forked from zovos/bot_tg

feat: TG Mini App - FastAPI backend + frontend SPA + Docker setup

This commit is contained in:
Omelechko Danil 2026-04-04 23:03:17 +03:00
parent 3039326100
commit c3487c22b4
14 changed files with 2078 additions and 3 deletions

2
.env
View file

@ -1,3 +1,3 @@
BOT_TOKEN=8156642467:AAEalekr-33lFS3tTTkXIEiaawfDzR6lBJc
TZ=Europe/Moscow
ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923
ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923

18
Dockerfile.api Normal file
View 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"]

View file

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

12
main.py
View file

@ -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))

1
webapp/__init__.py Normal file
View file

@ -0,0 +1 @@
# webapp package

394
webapp/api.py Normal file
View 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
View 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

View file

@ -0,0 +1,4 @@
FROM nginx:alpine
COPY . /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

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

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

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

View file

@ -0,0 +1,2 @@
fastapi>=0.111.0
uvicorn[standard]>=0.29.0