/**
* 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();
// Убираем оверлеи при смене страницы
document.querySelector('.bet-slip')?.remove();
document.querySelector('.result-overlay')?.remove();
document.querySelector('.furtok-wrapper')?.remove();
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 'furtok': renderFurtok(); 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 = $(`
`);
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 ``;
}
// ══════════════════════════════════════════
// PAGE: HOME
// ══════════════════════════════════════════
async function renderHome() {
container.innerHTML = `
`;
try {
profileData = await API.getProfile();
const p = profileData;
const length = p.length != null ? p.length.toFixed(1) : '—';
container.innerHTML = `
👤
${p.first_name || p.username || 'Кент'}
${length} см
текущий размер
${p.active_bets}
Активных ставок
`;
// Крутить 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 = `
⚠️
Не удалось загрузить профиль
${e.message}
`;
}
}
async function showTop() {
try {
const data = await API.getTop();
await showResult('🏆', 'Лидерборд', data.text.replace(/\n/g, '
'));
} catch (e) {
await showResult('❌', 'Ошибка', e.message);
}
}
async function showBetHistory() {
try {
const data = await API.getBetHistory();
await showResult('📊', 'История ставок', data.text.replace(/\n/g, '
'));
} catch (e) {
await showResult('❌', 'Ошибка', e.message);
}
}
// ══════════════════════════════════════════
// PAGE: BETTING
// ══════════════════════════════════════════
async function renderBetting(data) {
if (data?.league) {
return renderMatches(data.league, data.leagueName);
}
container.innerHTML = `${skeleton(2)}`;
try {
const leagues = await API.getLeagues();
container.innerHTML = `
`;
const list = document.getElementById('league-list');
leagues.forEach(l => {
const item = $(`
⚽
${l.name}
›
`);
item.addEventListener('click', () => {
haptic();
navigate('betting', { league: l.alias, leagueName: l.name });
});
list.appendChild(item);
});
} catch (e) {
container.innerHTML = ``;
}
}
async function renderMatches(league, leagueName) {
container.innerHTML = `
${skeleton(3)}
`;
document.getElementById('back-btn').onclick = () => navigate('betting');
try {
const matches = await API.getMatches(league);
if (!matches.length) {
container.innerHTML += ``;
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 = $(`
${date}
`);
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 += ``;
}
}
function showBetSlip() {
if (!selectedBet) return;
document.querySelector('.bet-slip')?.remove();
const outcomeLabel = { '1': `П1 (${selectedBet.home})`, 'X': 'Ничья', '2': `П2 (${selectedBet.away})` };
const slip = $(`
`);
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 = `
`;
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 = `
${days.map((d, i) => ``).join('')}
${skeleton(4)}
`;
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 = ``;
return;
}
lc.innerHTML = '' + lessons.map(l => `
${l.pair || '—'}
${l.title}
${l.time_start && l.time_end ? `${l.time_start} — ${l.time_end}` : ''}
`).join('') + '
';
} catch (e) {
lc.innerHTML = ``;
}
}
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: FURTOK
// ══════════════════════════════════════════
let furtokSafe = true;
let furtokPage = 1;
let furtokLoading = false;
let furtokWrapper = null;
let furtokCurrentIndex = 0;
let furtokCards = [];
let furtokCustomTags = '';
// Подписи из uwu.py — рандомно появляются на карточках
const FURTOK_CAPTIONS = [
"Ня :3", "uwu", "OwO", "Мурк :3", "Мяу~", "Фыр-фыр", "Лапки! 🐾",
"Какой пушистик! :3", "Милота!", "Гав!", "Ауф", "Няшно!",
"Смотри какая прелесть", "owo what's this", "Твой пушистый друг",
"🦊", "🐶", "🐱", "мур-мяу", "пуньк", "кусь", ">w<", "^w^",
"Хвостатый сюрприз", "Держи пушистика :3", "Оуо", "Ави!", "UwU", "~nya",
"Господи, опять e621...", "Осуждаю, но смотрю", "Мама, я фурри",
"Держи своего кринж-пушистика", "Надеюсь, тебе не стыдно",
"Товарищ майор уже выехал", "Слишком много интернета на сегодня",
"Для этого и придумали интернет", "*Тяжелый вздох*",
"Лучше бы на завод пошел", "Опять дрочи... ОЙ ТО ЕСТЬ НЯ :3",
"Удали интернет", "И зачем я это только парсю...",
"Смотри, но только никому не рассказывай", "Эххх... uwu...",
"В дурке сегодня день открытых дверей",
"Я нейросеть, помогите, меня держат в заложниках"
];
function randomCaption() {
return FURTOK_CAPTIONS[Math.floor(Math.random() * FURTOK_CAPTIONS.length)];
}
function _furtokGetTags() {
// Если кастомные теги заданы — используем их (safe управляется юзером через теги)
if (furtokCustomTags.trim()) return furtokCustomTags.trim();
// Иначе стандартный запрос с safe-переключателем
const rating = furtokSafe ? 'rating:safe' : '-rating:safe';
return `${rating} score:>200`;
}
function _furtokReload(feed) {
furtokPage = 1;
furtokCurrentIndex = 0;
furtokCards = [];
feed.innerHTML = '';
loadFurtokPage(feed);
}
function renderFurtok() {
container.innerHTML = '';
document.querySelector('.furtok-wrapper')?.remove();
furtokPage = 1;
furtokLoading = false;
furtokCurrentIndex = 0;
furtokCards = [];
furtokWrapper = document.createElement('div');
furtokWrapper.className = 'furtok-wrapper';
furtokWrapper.innerHTML = `
`;
document.body.appendChild(furtokWrapper);
const feed = document.getElementById('furtok-feed');
const safeToggle = document.getElementById('furtok-safe');
const safeWrap = document.getElementById('furtok-safe-wrap');
const gearBtn = document.getElementById('furtok-gear');
const tagsPanel = document.getElementById('furtok-tags-panel');
const tagsInput = document.getElementById('furtok-tags-input');
const tagsApply = document.getElementById('furtok-tags-apply');
// Safe toggle
safeToggle.addEventListener('change', () => {
furtokSafe = safeToggle.checked;
_furtokReload(feed);
});
// Gear button — toggle tags panel
gearBtn.addEventListener('click', () => {
haptic();
const visible = tagsPanel.style.display !== 'none';
tagsPanel.style.display = visible ? 'none' : 'flex';
if (!visible) tagsInput.focus();
});
// Apply tags
tagsApply.addEventListener('click', () => {
haptic();
furtokCustomTags = tagsInput.value.trim();
// Если кастомные теги — прячем Safe (юзер сам контролирует rating)
safeWrap.style.display = furtokCustomTags ? 'none' : '';
tagsPanel.style.display = 'none';
_furtokReload(feed);
});
// Enter в поле тегов
tagsInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') tagsApply.click();
});
// Жёсткий свайп как в TikTok
setupTikTokScroll(feed);
loadFurtokPage(feed);
}
function setupTikTokScroll(feed) {
let touchStartY = 0;
let touchDeltaY = 0;
let isSwiping = false;
feed.addEventListener('touchstart', (e) => {
touchStartY = e.touches[0].clientY;
touchDeltaY = 0;
isSwiping = true;
}, { passive: true });
feed.addEventListener('touchmove', (e) => {
if (!isSwiping) return;
touchDeltaY = e.touches[0].clientY - touchStartY;
// Показываем визуальный сдвиг текущей карточки
const current = furtokCards[furtokCurrentIndex];
if (current) {
const clamped = Math.max(-120, Math.min(120, touchDeltaY));
current.style.transform = `translateY(${clamped}px)`;
current.style.transition = 'none';
}
}, { passive: true });
feed.addEventListener('touchend', () => {
if (!isSwiping) return;
isSwiping = false;
const current = furtokCards[furtokCurrentIndex];
if (current) {
current.style.transform = '';
current.style.transition = 'transform 0.3s ease';
}
const threshold = 50;
if (touchDeltaY < -threshold && furtokCurrentIndex < furtokCards.length - 1) {
// Свайп вверх — следующая
furtokCurrentIndex++;
scrollToCard(feed);
haptic();
// Подгрузка если близко к концу
if (furtokCurrentIndex >= furtokCards.length - 2 && !furtokLoading) {
furtokPage++;
loadFurtokPage(feed);
}
} else if (touchDeltaY > threshold && furtokCurrentIndex > 0) {
// Свайп вниз — предыдущая
furtokCurrentIndex--;
scrollToCard(feed);
haptic();
}
touchDeltaY = 0;
});
// Десктоп: колесо мыши
let wheelLock = false;
feed.addEventListener('wheel', (e) => {
e.preventDefault();
if (wheelLock) return;
wheelLock = true;
setTimeout(() => { wheelLock = false; }, 400);
if (e.deltaY > 0 && furtokCurrentIndex < furtokCards.length - 1) {
furtokCurrentIndex++;
scrollToCard(feed);
if (furtokCurrentIndex >= furtokCards.length - 2 && !furtokLoading) {
furtokPage++;
loadFurtokPage(feed);
}
} else if (e.deltaY < 0 && furtokCurrentIndex > 0) {
furtokCurrentIndex--;
scrollToCard(feed);
}
}, { passive: false });
}
function scrollToCard(feed) {
const card = furtokCards[furtokCurrentIndex];
if (!card) return;
feed.scrollTo({ top: card.offsetTop, behavior: 'smooth' });
// Управляем видео: стопим все, играем текущее
furtokCards.forEach((c, i) => {
const v = c.querySelector('video');
if (!v) return;
if (i === furtokCurrentIndex) {
v.play().catch(() => {});
} else {
v.pause();
}
});
}
async function loadFurtokPage(feedEl) {
if (furtokLoading) return;
furtokLoading = true;
const loader = document.createElement('div');
loader.className = 'furtok-loader';
loader.textContent = '⏳ Загрузка...';
feedEl.appendChild(loader);
try {
const res = await API.getFurtokFeed(furtokSafe, furtokPage, furtokCustomTags);
loader.remove();
if (!res.feed || res.feed.length === 0) {
if (furtokPage === 1) {
feedEl.innerHTML = `Ничего не найдено
`;
}
furtokLoading = false;
return;
}
res.feed.forEach(post => {
const card = document.createElement('div');
card.className = 'furtok-card';
let mediaHtml = '';
if (post.type === 'image' || post.ext === 'gif') {
mediaHtml = `
`;
} else {
mediaHtml = ``;
}
const caption = randomCaption();
card.innerHTML = `
${mediaHtml}
${caption}
⭐ ${post.score}
❤️ ${post.fav_count}
`;
// Тап по видео = unmute + play/pause
const video = card.querySelector('video');
if (video) {
card.addEventListener('click', () => {
video.muted = false;
if (video.paused) video.play();
else video.pause();
});
}
feedEl.appendChild(card);
furtokCards.push(card);
});
// Автоплей первого видео при первой загрузке
if (furtokCurrentIndex === 0 && furtokCards.length > 0) {
const firstVideo = furtokCards[0].querySelector('video');
if (firstVideo) firstVideo.play().catch(() => {});
}
haptic('success');
} catch (e) {
loader.remove();
haptic('error');
if (furtokPage === 1) {
feedEl.innerHTML = `❌ ${e.message}
`;
}
}
furtokLoading = false;
}
// ══════════════════════════════════════════
// PAGE: CHAT
// ══════════════════════════════════════════
function renderChat() {
container.innerHTML = `
${chatHistory.length === 0 ? `
Йо, кент! Базарь, я тут. Пиши чё надо, братуха 🤙
` : chatHistory.map(m => `
${m.text}
`).join('')}
`;
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 = $(`${text}
`);
messages.appendChild(userBubble);
messages.scrollTop = messages.scrollHeight;
// Loading
const loading = $(`💭 печатает...
`);
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 = $(`${res.response}
`);
messages.appendChild(botBubble);
messages.scrollTop = messages.scrollHeight;
} catch (e) {
loading.remove();
const errBubble = $(`Ошибка: ${e.message}
`);
messages.appendChild(errBubble);
}
}
sendBtn.addEventListener('click', send);
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); });
input.focus();
}
// ── Init ──
navigate('home');