bot_pozhitki/static/app.js

376 lines
15 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",
categoryFilter: "",
summary: null,
config: null,
customCategories: { income: [], expense: [] },
};
const categories = {
income: ["Зарплата", "Фриланс", "Подарок", "Продажа", "Инвестиции", "Другое"],
expense: ["Продукты", "Жильё", "Транспорт", "Здоровье", "Развлечения", "Покупки", "Другое"],
};
const $ = (selector) => document.querySelector(selector);
const money = new Intl.NumberFormat("ru-RU", { style: "currency", currency: "RUB", maximumFractionDigits: 2 });
function initTelegram() {
if (!tg) return;
const safely = (action) => {
try { action(); } catch (error) { console.warn("Telegram WebApp method failed", error); }
};
safely(() => tg.ready());
safely(() => tg.expand());
safely(() => tg.enableClosingConfirmation?.());
if (tg.isVersionAtLeast?.("6.1")) safely(() => tg.setHeaderColor("secondary_bg_color"));
if (tg.isVersionAtLeast?.("7.10")) safely(() => tg.setBottomBarColor("secondary_bg_color"));
}
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(".bottom-nav button").forEach((button) => {
button.addEventListener("click", () => {
document.querySelectorAll(".bottom-nav button, .view").forEach((item) => item.classList.remove("active"));
button.classList.add("active");
$(`#${button.dataset.view}`).classList.add("active");
if (button.dataset.view === "leaderboardView") loadLeaderboard();
});
});
}
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;
try {
tg.HapticFeedback?.notificationOccurred("success");
tg.sendData(encoded);
return true;
} catch (error) {
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);
const percent = Number(summary.saved_percent);
$("#savedPercent").textContent = `${percent.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`;
$("#savingProgress").style.width = `${Math.max(0, Math.min(100, percent))}%`;
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);
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><small>${item.user_id === userId ? "Это вы" : money.format(item.balance_cents / 100)}</small></span>
<span class="leader-percent ${percent < 0 ? "negative" : ""}">${percent.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%</span>
</div>`;
}).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();
bindForm();
bindLeaderboard();
fillCategories();
loadSummary();
loadUserCategories();
})();