517 lines
20 KiB
JavaScript
517 lines
20 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",
|
||
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();
|
||
})();
|