(() => { "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"); 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"); window.setTimeout(() => $("#amount").focus(), 0); } 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) => `