diff --git a/.env b/.env
index 0322222..98f9cd2 100644
--- a/.env
+++ b/.env
@@ -1,3 +1,3 @@
BOT_TOKEN=8156642467:AAEalekr-33lFS3tTTkXIEiaawfDzR6lBJc
TZ=Europe/Moscow
-ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923
+ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923
\ No newline at end of file
diff --git a/Dockerfile.api b/Dockerfile.api
new file mode 100644
index 0000000..b31a72b
--- /dev/null
+++ b/Dockerfile.api
@@ -0,0 +1,18 @@
+FROM python:3.11-slim
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1
+WORKDIR /app
+
+COPY requirements.txt ./
+COPY webapp/requirements-api.txt ./requirements-api.txt
+RUN python -m pip install --upgrade pip \
+ && python -m pip install -r requirements.txt \
+ && python -m pip install -r requirements-api.txt
+
+COPY . .
+
+RUN mkdir -p /db
+
+EXPOSE 8080
+CMD ["python", "-m", "webapp.api"]
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 7432aa9..3dd4ddd 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -11,5 +11,32 @@ services:
PENIS_DB_PATH: /db/penis_stats.sqlite3
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
+ BET_DB_PATH: /db/bets.sqlite3
init: true
restart: unless-stopped
+
+ webapp-api:
+ build:
+ context: .
+ dockerfile: Dockerfile.api
+ volumes:
+ - ./db:/db
+ env_file:
+ - .env
+ environment:
+ TZ: Europe/Moscow
+ PENIS_DB_PATH: /db/penis_stats.sqlite3
+ CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
+ POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
+ BET_DB_PATH: /db/bets.sqlite3
+ API_PORT: "8080"
+ restart: unless-stopped
+
+ webapp-frontend:
+ build:
+ context: ./webapp/frontend
+ ports:
+ - "3000:80"
+ depends_on:
+ - webapp-api
+ restart: unless-stopped
diff --git a/games/uwu.py b/games/uwu.py
index 634e4f1..6cd9077 100644
--- a/games/uwu.py
+++ b/games/uwu.py
@@ -31,8 +31,9 @@ async def handle_uwu_cmd(message: types.Message):
if len(args) > 1:
tags = args[1].strip()
- tags += " order:random"
+ tags += " order:random score:>300"
+async def fetch_uwu_post(tags: str) -> dict:
url = "https://e621.net/posts.json"
params = {
"tags": tags,
@@ -47,28 +48,102 @@ async def handle_uwu_cmd(message: types.Message):
"User-Agent": f"Sex Bomba TG Bot/1.0 (by {config.E621_LOGIN or 'unknown'} on e621)"
}
- try:
- async with aiohttp.ClientSession() as session:
- async with session.get(url, params=params, headers=headers, auth=auth) as resp:
- if resp.status != 200:
- logger.error(f"e621 API Error: {resp.status}")
- await message.reply("Не удалось получить картинку от сервера :(")
- return
- data = await resp.json()
-
- posts = data.get("posts", [])
- if not posts:
- await message.reply("Ничего не найдено по этим тегам :(")
- return
+ async with aiohttp.ClientSession() as session:
+ async with session.get(url, params=params, headers=headers, auth=auth) as resp:
+ if resp.status != 200:
+ logger.error(f"e621 API Error: {resp.status}")
+ raise Exception("Не удалось получить картинку от сервера :(")
+ data = await resp.json()
- post = posts[0]
+ posts = data.get("posts", [])
+ if not posts:
+ raise Exception("Ничего не найдено по этим тегам :(")
+
+ post = posts[0]
+ file_url = post.get("file", {}).get("url")
+ ext = post.get("file", {}).get("ext", "png")
+ if not file_url:
+ raise Exception("У найденного поста нет прямого URL изображения :(")
+
+ caption = random.choice(CAPTIONS)
+ return {
+ "url": file_url,
+ "ext": ext,
+ "caption": caption
+ }
+
+async def fetch_furtok_feed(safe: bool = True, page: int = 1) -> list[dict]:
+ rating = "rating:safe" if safe else "-rating:safe"
+ tags = f"{rating} animated order:random score:>200"
+
+ url = "https://e621.net/posts.json"
+ params = {
+ "tags": tags,
+ "limit": 10,
+ "page": page,
+ }
+
+ auth = None
+ if config.E621_LOGIN and config.E621_API_KEY:
+ auth = aiohttp.BasicAuth(config.E621_LOGIN, config.E621_API_KEY)
+
+ headers = {
+ "User-Agent": f"Sex Bomba TG Bot/1.0 (by {config.E621_LOGIN or 'unknown'} on e621)"
+ }
+
+ async with aiohttp.ClientSession() as session:
+ async with session.get(url, params=params, headers=headers, auth=auth) as resp:
+ if resp.status != 200:
+ logger.error(f"e621 API Error: {resp.status}")
+ raise Exception("Не удалось загрузить ленту :(")
+ data = await resp.json()
+
+ posts = data.get("posts", [])
+ feed = []
+ for post in posts:
file_url = post.get("file", {}).get("url")
- ext = post.get("file", {}).get("ext", "png")
- if not file_url:
- await message.reply("У найденного поста нет прямого URL изображения :(")
- return
-
- caption = random.choice(CAPTIONS)
+ ext = post.get("file", {}).get("ext", "")
+ if not file_url or ext not in ("gif", "webm", "mp4"):
+ continue
+ sample_url = post.get("sample", {}).get("url") or file_url
+ feed.append({
+ "url": file_url,
+ "sample": sample_url,
+ "ext": ext,
+ "score": post.get("score", {}).get("total", 0),
+ "fav_count": post.get("fav_count", 0),
+ })
+
+ return feed
+
+async def handle_uwu_cmd(message: types.Message):
+ args = message.text.split(maxsplit=1)
+
+ if len(args) > 1 and args[1].strip() == "-h":
+ help_msg = (
+ "🐈 Справка по тегам e621:\n\n"
+ "Перечислите любые теги через пробел после команды.\n"
+ "• rating: rating:safe (безопасные), rating:questionable, rating:explicit (NSFW)\n"
+ "• score: score:>500 (искать популярные посты с рейтингом больше 500)\n"
+ "• Исключить тег: поставьте минус, например -fox\n\n"
+ "Пример: /uwu wolf -rating:explicit score:>100\n"
+ "По умолчанию: rating:safe score:>500 -animated"
+ )
+ await message.reply(help_msg, parse_mode="HTML")
+ return
+
+ tags = "rating:safe score:>500 -animated"
+ if len(args) > 1:
+ tags = args[1].strip()
+
+ tags += " order:random score:>300"
+
+ try:
+ post = await fetch_uwu_post(tags)
+ file_url = post["url"]
+ ext = post["ext"]
+ caption = post["caption"]
+
if ext in ("gif", "webm", "mp4"):
# Для анимаций: отправляем caption отдельным сообщением,
# а саму гифку/видео через URL напрямую — так Telegram
@@ -84,4 +159,7 @@ async def handle_uwu_cmd(message: types.Message):
except Exception as e:
logger.exception("Error in uwu command")
- await message.reply("Произошла ошибка при обращении к API :(")
+ if str(e) in ["Не удалось получить картинку от сервера :(", "Ничего не найдено по этим тегам :(", "У найденного поста нет прямого URL изображения :("]:
+ await message.reply(str(e))
+ else:
+ await message.reply("Произошла ошибка при обращении к API :(")
diff --git a/main.py b/main.py
index 6e7821e..64b337d 100644
--- a/main.py
+++ b/main.py
@@ -14,7 +14,8 @@ from aiogram.filters import Command, CommandStart
from aiogram.enums import ParseMode
from aiogram.types import (
BufferedInputFile, Message, BotCommand, FSInputFile,
- BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats
+ BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats,
+ WebAppInfo, InlineKeyboardMarkup, InlineKeyboardButton
)
from aiogram.client.default import DefaultBotProperties
@@ -971,7 +972,32 @@ 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:
+ if message.chat.type == "private":
+ kb = InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="🎮 Открыть Mini App", web_app=WebAppInfo(url=webapp_url))]
+ ])
+ await message.answer("Йо, кент! Жми кнопку 👇", reply_markup=kb)
+ else:
+ await message.answer("Йо, кент! Mini App доступно только в личных сообщениях. Пиши мне в личку!")
+ else:
+ await message.answer("Соси")
+
+async def handle_app_cmd(message: Message):
+ """Открыть Mini App."""
+ webapp_url = os.getenv("WEBAPP_URL", "")
+ if not webapp_url:
+ await message.answer("Mini App не настроен.")
+ return
+
+ if message.chat.type == "private":
+ kb = InlineKeyboardMarkup(inline_keyboard=[
+ [InlineKeyboardButton(text="🎮 Открыть Mini App", web_app=WebAppInfo(url=webapp_url))]
+ ])
+ await message.answer("⬇️ Жми кнопку чтобы открыть приложение", reply_markup=kb)
+ else:
+ await message.answer("Братуха, встроенная кнопка Mini App пока работает только в личке (ограничение Telegram). Перейди в личные сообщения с ботом или используй прямую ссылку: https://t.me/FenyaBotTest_bot/app (если она настроена).")
async def handle_schedule_cmd(message: Message):
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
@@ -1397,6 +1423,7 @@ def main():
# Регистрация хендлеров
dp.message.register(handle_start, CommandStart())
+ dp.message.register(handle_app_cmd, Command("app"))
dp.message.register(handle_help, Command("help"))
dp.message.register(handle_schedule_cmd, Command("schedule"))
dp.message.register(handle_break_cmd, Command("break"))
diff --git a/webapp/__init__.py b/webapp/__init__.py
new file mode 100644
index 0000000..f62e193
--- /dev/null
+++ b/webapp/__init__.py
@@ -0,0 +1 @@
+# webapp package
diff --git a/webapp/api.py b/webapp/api.py
new file mode 100644
index 0000000..a54f0a9
--- /dev/null
+++ b/webapp/api.py
@@ -0,0 +1,443 @@
+"""
+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 fastapi.staticfiles import StaticFiles
+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_match_by_index,
+ resolve_match_outcome,
+ 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
+from games.uwu import fetch_uwu_post, fetch_furtok_feed
+
+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):
+ home = m.get("home", "?")
+ away = m.get("away", "?")
+ odds = m.get("odds", {})
+
+ odds_home = odds.get(home)
+ odds_away = odds.get(away)
+
+ _DRAW_NAMES = {"draw", "tie", "ничья"}
+ draw_name = next((name for name in odds if name.casefold() in _DRAW_NAMES), None)
+ odds_draw = odds.get(draw_name) if draw_name else None
+
+ result.append(MatchResponse(
+ index=i + 1,
+ home=home,
+ away=away,
+ commence=m.get("commence", ""),
+ 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"]
+
+ # 1. Найти матч по индексу
+ match = await get_match_by_index(req.league, req.match_index)
+ if not match:
+ raise HTTPException(status_code=404, detail="Матч не найден")
+
+ # 2. Преобразовать код исхода (1/X/2) в название команды
+ team = resolve_match_outcome(match, req.outcome)
+ if not team:
+ raise HTTPException(status_code=400, detail="Неверный исход ставки")
+
+ # 3. Разместить ставку (синхронная функция)
+ result = await asyncio.to_thread(place_bet, user_id, match, req.league, team, 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
+
+
+# --- UwU Арты ---
+
+@app.get("/api/uwu")
+async def get_uwu_api(
+ tags: str = "rating:safe score:>500 -animated",
+ user: dict = Depends(get_current_user),
+):
+ """Отправить случайный uwu арт по тегам."""
+ try:
+ tags += " order:random score:>300"
+ post = await fetch_uwu_post(tags)
+ return {"response": post}
+ except Exception as e:
+ logger.exception("UwU API failed")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# --- FurTok Лента ---
+
+@app.get("/api/furtok")
+async def get_furtok_feed_api(
+ safe: bool = True,
+ page: int = 1,
+ user: dict = Depends(get_current_user),
+):
+ """Получить ленту анимированных постов для FurTok."""
+ try:
+ feed = await fetch_furtok_feed(safe=safe, page=page)
+ return {"feed": feed}
+ except Exception as e:
+ logger.exception("FurTok API failed")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# --- ИИ Чат ---
+
+@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")
+
+
+# ──────────────────── Static files (для локального теста) ────────────────────
+
+_frontend_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend")
+if os.path.isdir(_frontend_dir):
+ app.mount("/", StaticFiles(directory=_frontend_dir, html=True), name="frontend")
+
+
+# ──────────────────── Entry point ────────────────────
+
+if __name__ == "__main__":
+ import uvicorn
+ port = int(os.getenv("API_PORT", "8080"))
+ uvicorn.run(app, host="0.0.0.0", port=port)
diff --git a/webapp/auth.py b/webapp/auth.py
new file mode 100644
index 0000000..8b0c43a
--- /dev/null
+++ b/webapp/auth.py
@@ -0,0 +1,82 @@
+"""
+Telegram Mini App — валидация initData.
+
+Telegram передаёт подписанные данные при открытии WebApp.
+Мы проверяем HMAC-SHA256 подпись через BOT_TOKEN,
+чтобы убедиться что запрос пришёл от реального пользователя.
+
+Документация: https://core.telegram.org/bots/webapps#validating-data
+"""
+
+import hashlib
+import hmac
+import json
+import os
+import time
+from urllib.parse import parse_qs
+
+BOT_TOKEN = os.getenv("BOT_TOKEN", "")
+
+
+def validate_init_data(init_data: str, max_age_seconds: int = 86400) -> dict | None:
+ """
+ Валидирует initData из Telegram WebApp.
+
+ Возвращает dict с данными пользователя при успехе, None при ошибке.
+ """
+ if not init_data or not BOT_TOKEN:
+ return None
+
+ try:
+ parsed = parse_qs(init_data, keep_blank_values=True)
+
+ # Извлекаем hash
+ received_hash = parsed.get("hash", [None])[0]
+ if not received_hash:
+ return None
+
+ # Собираем data-check-string (все поля кроме hash, отсортированные)
+ data_pairs = []
+ for key, values in parsed.items():
+ if key == "hash":
+ continue
+ data_pairs.append(f"{key}={values[0]}")
+ data_pairs.sort()
+ data_check_string = "\n".join(data_pairs)
+
+ # Вычисляем секретный ключ
+ secret_key = hmac.new(
+ b"WebAppData", BOT_TOKEN.encode("utf-8"), hashlib.sha256
+ ).digest()
+
+ # Вычисляем HMAC
+ computed_hash = hmac.new(
+ secret_key, data_check_string.encode("utf-8"), hashlib.sha256
+ ).hexdigest()
+
+ if not hmac.compare_digest(computed_hash, received_hash):
+ return None
+
+ # Проверяем auth_date (не старше max_age_seconds)
+ auth_date_str = parsed.get("auth_date", [None])[0]
+ if auth_date_str:
+ auth_date = int(auth_date_str)
+ if time.time() - auth_date > max_age_seconds:
+ return None
+
+ # Парсим user
+ user_str = parsed.get("user", [None])[0]
+ if not user_str:
+ return None
+
+ user_data = json.loads(user_str)
+ return {
+ "user_id": user_data.get("id"),
+ "first_name": user_data.get("first_name", ""),
+ "last_name": user_data.get("last_name", ""),
+ "username": user_data.get("username", ""),
+ "language_code": user_data.get("language_code", "ru"),
+ }
+
+ except Exception:
+ return None
diff --git a/webapp/frontend/Dockerfile b/webapp/frontend/Dockerfile
new file mode 100644
index 0000000..01bb247
--- /dev/null
+++ b/webapp/frontend/Dockerfile
@@ -0,0 +1,4 @@
+FROM nginx:alpine
+COPY . /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+EXPOSE 80
diff --git a/webapp/frontend/css/style.css b/webapp/frontend/css/style.css
new file mode 100644
index 0000000..8347cf0
--- /dev/null
+++ b/webapp/frontend/css/style.css
@@ -0,0 +1,1007 @@
+/* ═══════════════════════════════════════════
+ 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;
+}
+
+/* ── FurTok Feed ── */
+
+.furtok-wrapper {
+ position: fixed;
+ top: 0;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 100%;
+ max-width: 480px;
+ height: calc(100vh - var(--nav-height));
+ overflow: hidden;
+ z-index: 10;
+}
+
+.furtok-feed {
+ height: 100%;
+ overflow-y: scroll;
+ scroll-snap-type: y mandatory;
+ scrollbar-width: none;
+}
+
+.furtok-feed::-webkit-scrollbar { display: none; }
+
+.furtok-card {
+ height: 100%;
+ scroll-snap-align: start;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #000;
+ overflow: hidden;
+}
+
+.furtok-card video,
+.furtok-card img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+
+.furtok-overlay {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ padding: 16px;
+ background: linear-gradient(transparent, rgba(0,0,0,0.7));
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ pointer-events: none;
+}
+
+.furtok-stats {
+ display: flex;
+ gap: 16px;
+ font-size: 13px;
+ font-weight: 600;
+ color: rgba(255,255,255,0.85);
+}
+
+.furtok-header {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 16px;
+ background: linear-gradient(rgba(0,0,0,0.5), transparent);
+ z-index: 5;
+}
+
+.furtok-title {
+ font-size: 18px;
+ font-weight: 800;
+ color: white;
+ text-shadow: 0 1px 4px rgba(0,0,0,0.5);
+}
+
+.furtok-toggle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ font-weight: 600;
+ color: rgba(255,255,255,0.8);
+}
+
+.furtok-toggle input[type="checkbox"] {
+ appearance: none;
+ -webkit-appearance: none;
+ width: 40px;
+ height: 22px;
+ background: rgba(255,255,255,0.2);
+ border-radius: 11px;
+ position: relative;
+ cursor: pointer;
+ transition: background 0.3s;
+}
+
+.furtok-toggle input[type="checkbox"]::after {
+ content: '';
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 18px;
+ height: 18px;
+ background: white;
+ border-radius: 50%;
+ transition: transform 0.3s;
+}
+
+.furtok-toggle input[type="checkbox"]:checked {
+ background: var(--green-500);
+}
+
+.furtok-toggle input[type="checkbox"]:checked::after {
+ transform: translateX(18px);
+}
+
+.furtok-loader {
+ height: 100%;
+ scroll-snap-align: start;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--bg-primary);
+ color: var(--text-secondary);
+ font-size: 14px;
+}
diff --git a/webapp/frontend/index.html b/webapp/frontend/index.html
new file mode 100644
index 0000000..02a7753
--- /dev/null
+++ b/webapp/frontend/index.html
@@ -0,0 +1,46 @@
+
+
+