bot_pozhitki/static/app.js
2026-09-03 18:18:08 +00:00

236 lines
9.5 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 user = tgUser || (previewUserId ? { id: previewUserId, first_name: "Предпросмотр" } : null);
const userId = Number(user?.id) || 0;
const state = { kind: "income", board: "all", summary: null, config: null };
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;
tg.ready();
tg.expand();
tg.enableClosingConfirmation?.();
if (tg.isVersionAtLeast?.("6.1")) tg.setHeaderColor("secondary_bg_color");
if (tg.isVersionAtLeast?.("7.10")) 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() {
const name = [user?.first_name, user?.last_name].filter(Boolean).join(" ") || "Гость";
$("#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");
select.innerHTML = categories[state.kind].map((category) => `<option value="${category}">${category}</option>`).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 === "Другое";
$("#customCategoryWrap").classList.toggle("hidden", !custom);
$("#customCategory").required = custom;
if (custom) $("#customCategory").focus();
});
$("#transactionForm").addEventListener("submit", submitTransaction);
}
function submitTransaction(event) {
event.preventDefault();
const amount = Number($("#amount").value);
const selected = $("#category").value;
const category = selected === "Другое" ? $("#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(),
};
const encoded = JSON.stringify(payload);
if (encoded.length > 4096) {
showToast("Запись получилась слишком длинной");
return;
}
if (!tg?.sendData || !tg.initData) {
showToast("Сохранение доступно при открытии из Telegram");
return;
}
tg.HapticFeedback?.notificationOccurred("success");
tg.sendData(encoded);
}
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`);
renderSummary(state.summary);
} catch (error) {
console.warn("Summary is 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;
}
function bindLeaderboard() {
$("#allBoardButton").addEventListener("click", () => switchBoard("all"));
$("#friendsBoardButton").addEventListener("click", () => switchBoard("friends"));
$("#shareButton").addEventListener("click", shareInvite);
}
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);
}
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();
})();