(() => { "use strict"; const tg = window.Telegram?.WebApp; const tgUser = tg?.initDataUnsafe?.user; const query = new URLSearchParams(window.location.search); const previewUserId = Number(query.get("user_id")) || 0; const keyboardLaunch = query.get("mode") === "keyboard"; const user = tgUser || (previewUserId ? { id: previewUserId } : null); const userId = Number(user?.id) || 0; const state = { kind: "income", board: "all", currentView: "dashboardView", previousView: "dashboardView", categoryFilter: "", summary: null, config: null, customCategories: { income: [], expense: [] }, }; const viewOrder = ["dashboardView", "historyView", "leaderboardView", "addView"]; let transitionCleanupTimer = 0; let backgroundViewportHeight = 0; const categories = { income: ["Зарплата", "Фриланс", "Подарок", "Продажа", "Инвестиции", "Другое"], expense: ["Продукты", "Жильё", "Транспорт", "Здоровье", "Развлечения", "Покупки", "Другое"], }; const $ = (selector) => document.querySelector(selector); const money = new Intl.NumberFormat("ru-RU", { style: "currency", currency: "RUB", maximumFractionDigits: 2 }); function safeTelegram(action) { try { action(); } catch (error) { console.warn("Telegram WebApp method failed", error); } } function initTelegram() { captureBackgroundViewport(); window.visualViewport?.addEventListener("resize", captureBackgroundViewport); window.addEventListener("resize", captureBackgroundViewport); if (!tg) return; safeTelegram(() => tg.ready()); safeTelegram(() => tg.expand()); if (tg.isVersionAtLeast?.("8.0")) safeTelegram(() => tg.requestFullscreen()); safeTelegram(() => tg.enableClosingConfirmation?.()); if (tg.isVersionAtLeast?.("6.1")) safeTelegram(() => tg.setBackgroundColor("#111111")); if (tg.isVersionAtLeast?.("6.9")) safeTelegram(() => tg.setHeaderColor("#111111")); if (tg.isVersionAtLeast?.("7.7")) safeTelegram(() => tg.disableVerticalSwipes()); if (tg.isVersionAtLeast?.("7.10")) safeTelegram(() => tg.setBottomBarColor("#111111")); safeTelegram(() => tg.onEvent?.("viewportChanged", captureBackgroundViewport)); safeTelegram(() => tg.onEvent?.("fullscreenChanged", captureBackgroundViewport)); window.setTimeout(captureBackgroundViewport, 350); window.setTimeout(captureBackgroundViewport, 900); } function captureBackgroundViewport() { const height = Math.round(Math.max( Number(tg?.viewportStableHeight) || 0, Number(window.visualViewport?.height) || 0, window.innerHeight || 0, )); if (height <= backgroundViewportHeight) return; backgroundViewportHeight = height; document.documentElement.style.setProperty("--app-background-height", `${height}px`); } function showToast(message) { const toast = $("#toast"); toast.textContent = message; toast.classList.add("show"); window.setTimeout(() => toast.classList.remove("show"), 2600); } function setIdentity(profile = user) { const name = profile?.nickname || [profile?.first_name, profile?.last_name].filter(Boolean).join(" ") || (userId ? "Загрузка…" : "Откройте из бота"); $("#userName").textContent = name; $("#userInitial").textContent = name.slice(0, 1).toUpperCase(); } function bindNavigation() { document.querySelectorAll(".nav-tabs button").forEach((button) => { button.addEventListener("click", () => showView(button.dataset.view)); }); } function showView(viewId) { const previousView = state.currentView; if (viewId === previousView) { if (viewId === "leaderboardView") loadLeaderboard(); return; } const previousIndex = viewOrder.indexOf(previousView); const nextIndex = viewOrder.indexOf(viewId); const direction = nextIndex >= previousIndex ? "forward" : "backward"; document.querySelectorAll(".nav-tabs button, .view").forEach((item) => item.classList.remove("active")); $(`#${viewId}`).classList.add("active"); document.querySelector(`.bottom-nav button[data-view="${viewId}"]`)?.classList.add("active"); state.currentView = viewId; document.body.classList.toggle("form-open", viewId === "addView"); document.body.classList.toggle("leaderboard-open", viewId === "leaderboardView"); updateNativeControls(); window.scrollTo({ top: 0, behavior: "auto" }); animateViewStage(direction); if (viewId === "leaderboardView") loadLeaderboard(); } function animateViewStage(direction) { const stage = $("#viewStage"); const className = direction === "backward" ? "transition-backward" : "transition-forward"; window.clearTimeout(transitionCleanupTimer); stage.onanimationend = null; stage.classList.remove("transition-forward", "transition-backward"); void stage.offsetWidth; stage.classList.add(className); const cleanup = () => { stage.classList.remove("transition-forward", "transition-backward"); stage.onanimationend = null; }; stage.onanimationend = cleanup; transitionCleanupTimer = window.setTimeout(cleanup, 400); } function openAddView() { if (state.currentView !== "addView") state.previousView = state.currentView; showView("addView"); } function closeAddView() { showView(state.previousView || "dashboardView"); } function setupNativeControls() { $("#appActionButton").addEventListener("click", () => { if (state.currentView === "addView") { $("#transactionForm").requestSubmit(); } else { openAddView(); } }); if (tg) safeTelegram(() => tg.BackButton?.onClick(closeAddView)); updateNativeControls(); } function updateNativeControls() { const isForm = state.currentView === "addView"; const action = $("#appActionButton"); action.textContent = isForm ? "Сохранить" : "+"; action.setAttribute("aria-label", isForm ? "Сохранить операцию" : "Добавить запись"); if (!tg) return; safeTelegram(() => tg.MainButton?.hide()); safeTelegram(() => isForm ? tg.BackButton?.show() : tg.BackButton?.hide()); } function fillCategories() { const select = $("#category"); const items = [...new Set([...categories[state.kind], ...state.customCategories[state.kind]])]; select.innerHTML = items.map((category) => ``).join("") + ''; $("#customCategoryWrap").classList.add("hidden"); $("#customCategory").required = false; } function bindForm() { document.querySelectorAll(".segment").forEach((button) => { button.addEventListener("click", () => { document.querySelectorAll(".segment").forEach((item) => item.classList.remove("active")); button.classList.add("active"); state.kind = button.dataset.kind; fillCategories(); }); }); $("#category").addEventListener("change", (event) => { const custom = event.target.value === "__new__"; $("#customCategoryWrap").classList.toggle("hidden", !custom); $("#customCategory").required = custom; if (custom) $("#customCategory").focus(); }); $("#saveCategoryButton").addEventListener("click", saveCategory); $("#transactionForm").addEventListener("submit", submitTransaction); } function submitTransaction(event) { event.preventDefault(); const amount = Number($("#amount").value); const selected = $("#category").value; const category = selected === "__new__" ? $("#customCategory").value.trim() : selected; if (!Number.isFinite(amount) || amount <= 0 || !category) { showToast("Проверьте сумму и категорию"); return; } const payload = { v: 1, type: "transaction", kind: state.kind, amount: amount.toFixed(2), category: category.slice(0, 40), note: $("#note").value.trim().slice(0, 160), occurred_at: new Date().toISOString(), }; sendTelegramPayload(payload); } function saveCategory() { const category = $("#customCategory").value.trim(); if (!category) { showToast("Введите название категории"); return; } sendTelegramPayload({ v: 1, type: "category_create", kind: state.kind, category: category.slice(0, 40), }); } function canSendViaTelegram() { const isTelegramClient = Boolean(tg?.platform && tg.platform !== "unknown"); if (!tg?.sendData || !isTelegramClient || !keyboardLaunch) { showToast("Отправьте /start боту и откройте приложение кнопкой под сообщением"); return false; } return true; } function sendTelegramPayload(payload) { const encoded = JSON.stringify(payload); if (encoded.length > 4096) { showToast("Запись получилась слишком длинной"); return false; } if (!canSendViaTelegram()) return false; const action = $("#appActionButton"); try { action.disabled = true; action.textContent = "Сохраняем…"; tg.HapticFeedback?.notificationOccurred("success"); tg.sendData(encoded); return true; } catch (error) { action.disabled = false; updateNativeControls(); console.error("Telegram sendData failed", error); showToast("Telegram не принял запись. Обновите приложение и попробуйте снова"); return false; } } async function fetchJson(url) { const response = await fetch(url, { headers: { Accept: "application/json" } }); if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json(); } async function loadSummary() { if (!userId) return; try { state.summary = await fetchJson(`../api/users/${userId}/summary`); setIdentity(state.summary); renderSummary(state.summary); await loadTransactions(); } catch (error) { console.warn("Summary is not available", error); } } async function loadUserCategories() { if (!userId) return; try { const data = await fetchJson(`../api/users/${userId}/categories`); for (const item of data.items || []) { if (state.customCategories[item.kind] && !state.customCategories[item.kind].includes(item.name)) { state.customCategories[item.kind].push(item.name); } } fillCategories(); } catch (error) { console.warn("User categories are not available", error); } } function renderSummary(summary) { $("#balanceValue").textContent = money.format(summary.balance_cents / 100); $("#incomeValue").textContent = money.format(summary.income_cents / 100); $("#expenseValue").textContent = money.format(summary.expense_cents / 100); renderCategories(summary.categories || []); } function renderCategories(items) { const list = $("#categoryList"); if (!items.length) return; const max = Math.max(...items.map((item) => item.amount_cents)); $("#categoryCaption").textContent = `${items.length} категорий`; list.classList.remove("empty-state"); list.innerHTML = items.slice(0, 8).map((item) => `
${escapeHtml(item.category)} ${item.kind === "income" ? "+" : "−"}${money.format(item.amount_cents / 100)}
`).join(""); } function escapeHtml(value) { const element = document.createElement("span"); element.textContent = value; return element.innerHTML; } async function loadTransactions() { if (!userId) return; try { const data = await fetchJson(`../api/users/${userId}/transactions?limit=30`); renderTransactions(data.items || []); } catch (error) { console.warn("Transactions are not available", error); } } function renderTransactions(items) { const list = $("#transactionList"); if (!items.length) { list.className = "transaction-list empty-state"; list.textContent = "Записей пока нет."; return; } list.className = "transaction-list"; list.innerHTML = items.map((item) => { const income = item.kind === "income"; const date = new Date(item.occurred_at).toLocaleDateString("ru-RU", { day: "numeric", month: "short" }); const detail = [date, item.note].filter(Boolean).map(escapeHtml).join(" · "); return `
${income ? "↗" : "↘"} ${escapeHtml(item.category)}${detail} ${income ? "+" : "−"}${money.format(item.amount_cents / 100)}
`; }).join(""); list.querySelectorAll(".cancel-button").forEach((button) => { button.addEventListener("click", () => cancelTransaction(Number(button.dataset.transactionId))); }); } function cancelTransaction(transactionId) { const send = (confirmed) => { if (!confirmed) return; sendTelegramPayload({ v: 1, type: "transaction_cancel", transaction_id: transactionId }); }; if (tg?.showConfirm && tg.isVersionAtLeast?.("6.2")) { try { tg.showConfirm("Отменить запись? Она перестанет участвовать в расчётах.", send); return; } catch (error) { console.warn("Telegram confirmation failed", error); } } send(window.confirm("Отменить запись? Она перестанет участвовать в расчётах.")); } function bindLeaderboard() { $("#allBoardButton").addEventListener("click", () => switchBoard("all")); $("#friendsBoardButton").addEventListener("click", () => switchBoard("friends")); $("#shareButton").addEventListener("click", shareInvite); $("#leaderboardCategory").addEventListener("change", (event) => { state.categoryFilter = event.target.value; loadLeaderboard(); }); loadLeaderboardCategories(); } async function loadLeaderboardCategories() { try { const data = await fetchJson("../api/categories"); const names = [...new Set([ ...categories.income, ...categories.expense, ...(data.items || []).map((item) => item.name), ])].sort((a, b) => a.localeCompare(b, "ru")); $("#leaderboardCategory").innerHTML = '' + names.map((name) => ``).join(""); } catch (error) { console.warn("Leaderboard categories are not available", error); } } function switchBoard(board) { state.board = board; $("#allBoardButton").classList.toggle("active", board === "all"); $("#friendsBoardButton").classList.toggle("active", board === "friends"); loadLeaderboard(); } async function loadLeaderboard() { const list = $("#leaderboardList"); list.className = "leaderboard-list loading"; list.textContent = "Загружаем рейтинг…"; $("#friendHint").classList.add("hidden"); try { let url = "../api/leaderboard?limit=100"; if (state.board === "friends") { if (!userId) throw new Error("No Telegram user"); const friends = await fetchJson(`../api/users/${userId}/friends`); const ids = [userId, ...friends.user_ids]; url = `../api/leaderboard?user_ids=${encodeURIComponent(ids.join(","))}`; $("#friendHint").classList.toggle("hidden", friends.user_ids.length > 0); } if (state.categoryFilter) { url += `&category=${encodeURIComponent(state.categoryFilter)}`; } const data = await fetchJson(url); renderLeaderboard(data.items || []); } catch (error) { list.textContent = "Не удалось загрузить рейтинг"; console.warn("Leaderboard is not available", error); } } function renderLeaderboard(items) { const list = $("#leaderboardList"); list.className = "leaderboard-list"; if (!items.length) { list.classList.add("loading"); list.textContent = "В рейтинге пока никого нет"; return; } list.innerHTML = items.map((item, index) => { const initial = escapeHtml((item.nickname || "?").slice(0, 1).toUpperCase()); const avatar = item.avatar_url ? `` : `${initial}`; const percent = Number(item.saved_percent); return `
${index + 1} ${avatar} ${escapeHtml(item.nickname)}${item.user_id === userId ? "Это вы" : money.format(item.balance_cents / 100)} ${percent.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%
`; }).join(""); } async function shareInvite() { if (!userId) { showToast("Откройте приложение из Telegram"); return; } try { state.config ||= await fetchJson("../api/config"); if (!state.config.bot_username) throw new Error("Bot username is missing"); const invite = `https://t.me/${state.config.bot_username}?start=friend_${userId}`; const shareUrl = `https://t.me/share/url?url=${encodeURIComponent(invite)}&text=${encodeURIComponent("Давай копить вместе в Пожитках")}`; if (tg?.openTelegramLink) tg.openTelegramLink(shareUrl); else window.open(shareUrl, "_blank", "noopener,noreferrer"); } catch (error) { showToast("Ссылка появится после запуска бота"); } } initTelegram(); setIdentity(); bindNavigation(); setupNativeControls(); bindForm(); bindLeaderboard(); fillCategories(); loadSummary(); loadUserCategories(); })();