bot_pozhitki/static/app.js
2026-09-07 21:25:44 +00:00

517 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
"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 bootstrapSummaryFromUrl() {
const nickname = query.get("nickname")?.trim();
const values = ["balance", "income", "expense"].map((key) => {
const raw = query.get(key);
return raw !== null && /^-?\d+$/.test(raw) ? Number(raw) : NaN;
});
if (!nickname || values.some((value) => !Number.isSafeInteger(value))) return null;
return {
nickname,
balance_cents: values[0],
income_cents: values[1],
expense_cents: values[2],
categories: [],
};
}
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) => `<option value="${escapeHtml(category)}">${escapeHtml(category)}</option>`).join("")
+ '<option value="__new__"> Новая категория…</option>';
$("#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) => `
<div class="category-item ${item.kind === "expense" ? "expense-item" : ""}">
<strong>${escapeHtml(item.category)}</strong>
<span>${item.kind === "income" ? "+" : ""}${money.format(item.amount_cents / 100)}</span>
<div class="category-bar"><i style="width:${Math.max(4, item.amount_cents * 100 / max)}%"></i></div>
</div>`).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 `<div class="transaction-row ${income ? "" : "expense-row"}">
<span class="transaction-kind">${income ? "↗" : "↘"}</span>
<span class="transaction-copy"><strong>${escapeHtml(item.category)}</strong><small>${detail}</small></span>
<span class="transaction-actions">
<strong>${income ? "+" : ""}${money.format(item.amount_cents / 100)}</strong>
<button class="cancel-button" type="button" data-transaction-id="${item.id}">Отменить</button>
</span>
</div>`;
}).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 = '<option value="">Все категории</option>'
+ names.map((name) => `<option value="${escapeHtml(name)}">${escapeHtml(name)}</option>`).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
? `<img class="leader-avatar" src="..${item.avatar_url}" alt="" loading="lazy" />`
: `<span class="leader-avatar avatar-fallback">${initial}</span>`;
const percent = Number(item.saved_percent);
const canRemove = state.board === "friends" && item.user_id !== userId;
return `<div class="leader-row ${item.user_id === userId ? "me" : ""}">
<span class="rank">${index + 1}</span>
${avatar}
<span class="leader-name"><strong>${escapeHtml(item.nickname)}</strong>${item.user_id === userId ? "<small>Это вы</small>" : ""}</span>
<span class="leader-actions">
<strong class="leader-percent ${percent < 0 ? "negative" : ""}">${percent.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%</strong>
${canRemove ? `<button class="friend-remove" type="button" data-friend-user-id="${item.user_id}">Удалить</button>` : ""}
</span>
</div>`;
}).join("");
list.querySelectorAll(".friend-remove").forEach((button) => {
button.addEventListener("click", () => removeFriend(Number(button.dataset.friendUserId)));
});
}
function removeFriend(friendUserId) {
const send = (confirmed) => {
if (!confirmed) return;
sendTelegramPayload({ v: 1, type: "friend_remove", friend_user_id: friendUserId });
};
if (tg?.showConfirm && tg.isVersionAtLeast?.("6.2")) {
try {
tg.showConfirm("Удалить пользователя из друзей?", send);
return;
} catch (error) {
console.warn("Telegram confirmation failed", error);
}
}
send(window.confirm("Удалить пользователя из друзей?"));
}
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();
const bootstrapSummary = bootstrapSummaryFromUrl();
if (bootstrapSummary) {
state.summary = bootstrapSummary;
setIdentity(bootstrapSummary);
renderSummary(bootstrapSummary);
} else {
setIdentity();
}
bindNavigation();
setupNativeControls();
bindForm();
bindLeaderboard();
fillCategories();
loadSummary();
loadUserCategories();
})();