376 lines
15 KiB
JavaScript
376 lines
15 KiB
JavaScript
(() => {
|
||
"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();
|
||
})();
|