2524 lines
84 KiB
JavaScript
2524 lines
84 KiB
JavaScript
(() => {
|
||
const THEME_KEY = "ef_theme";
|
||
|
||
function getTheme() {
|
||
return document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark";
|
||
}
|
||
|
||
function applyTheme(theme) {
|
||
const t = theme === "light" ? "light" : "dark";
|
||
document.documentElement.setAttribute("data-theme", t);
|
||
document.documentElement.setAttribute("data-bs-theme", t);
|
||
try {
|
||
localStorage.setItem(THEME_KEY, t);
|
||
} catch (_) {}
|
||
const tg = window.Telegram?.WebApp;
|
||
if (tg) {
|
||
const color = t === "light" ? "#eef3f0" : "#0a0f0d";
|
||
try {
|
||
tg.setHeaderColor(color);
|
||
tg.setBackgroundColor(color);
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
|
||
const tg = window.Telegram?.WebApp;
|
||
if (tg) {
|
||
tg.ready();
|
||
tg.expand();
|
||
applyTheme(getTheme());
|
||
document.documentElement.classList.add("is-telegram");
|
||
document.body.classList.add("is-telegram");
|
||
try {
|
||
if (typeof tg.disableVerticalSwipes === "function") tg.disableVerticalSwipes();
|
||
} catch (_) {}
|
||
}
|
||
|
||
const initData = tg?.initData || "";
|
||
const TOKEN_KEY = "vpn_session_token";
|
||
const $ = (sel) => document.querySelector(sel);
|
||
|
||
document.getElementById("btn-theme-toggle")?.addEventListener("click", () => {
|
||
applyTheme(getTheme() === "dark" ? "light" : "dark");
|
||
});
|
||
|
||
function setBalancePill(amount) {
|
||
const el = $("#balance-pill");
|
||
if (!el) return;
|
||
if (amount === null || amount === undefined || amount === "" || amount === "—") {
|
||
el.innerHTML = `<i class="fa-solid fa-wallet"></i> —`;
|
||
return;
|
||
}
|
||
const n = typeof amount === "number" ? Math.round(amount) : amount;
|
||
el.innerHTML = `<i class="fa-solid fa-wallet"></i> ${n} ₽`;
|
||
}
|
||
|
||
const state = {
|
||
me: null,
|
||
tariffs: [],
|
||
botUsername: "",
|
||
telegramClientId: "",
|
||
authed: false,
|
||
starsEnabled: false,
|
||
starsRubPerStar: 1.5,
|
||
topup: null,
|
||
};
|
||
|
||
function getStoredToken() {
|
||
try {
|
||
return localStorage.getItem(TOKEN_KEY) || "";
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function setStoredToken(token) {
|
||
try {
|
||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||
else localStorage.removeItem(TOKEN_KEY);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
let qrPollTimer = null;
|
||
|
||
function b64urlToBuf(b64) {
|
||
const pad = "=".repeat((4 - ((b64 || "").length % 4)) % 4);
|
||
const str = atob(String(b64 || "").replace(/-/g, "+").replace(/_/g, "/") + pad);
|
||
const buf = new Uint8Array(str.length);
|
||
for (let i = 0; i < str.length; i++) buf[i] = str.charCodeAt(i);
|
||
return buf.buffer;
|
||
}
|
||
|
||
function bufToB64url(buf) {
|
||
const bytes = new Uint8Array(buf);
|
||
let s = "";
|
||
bytes.forEach((b) => {
|
||
s += String.fromCharCode(b);
|
||
});
|
||
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||
}
|
||
|
||
function preparePublicKeyCreate(options) {
|
||
const o = { ...options };
|
||
o.challenge = b64urlToBuf(options.challenge);
|
||
if (o.user?.id) o.user = { ...o.user, id: b64urlToBuf(o.user.id) };
|
||
if (Array.isArray(o.excludeCredentials)) {
|
||
o.excludeCredentials = o.excludeCredentials.map((c) => ({
|
||
...c,
|
||
id: b64urlToBuf(c.id),
|
||
}));
|
||
}
|
||
return o;
|
||
}
|
||
|
||
function preparePublicKeyGet(options) {
|
||
const o = { ...options };
|
||
o.challenge = b64urlToBuf(options.challenge);
|
||
if (Array.isArray(o.allowCredentials)) {
|
||
o.allowCredentials = o.allowCredentials.map((c) => ({
|
||
...c,
|
||
id: b64urlToBuf(c.id),
|
||
}));
|
||
}
|
||
return o;
|
||
}
|
||
|
||
function credentialToJSON(cred) {
|
||
const response = cred.response;
|
||
const out = {
|
||
id: cred.id,
|
||
rawId: bufToB64url(cred.rawId),
|
||
type: cred.type,
|
||
authenticatorAttachment: cred.authenticatorAttachment || undefined,
|
||
response: {
|
||
clientDataJSON: bufToB64url(response.clientDataJSON),
|
||
attestationObject: response.attestationObject
|
||
? bufToB64url(response.attestationObject)
|
||
: undefined,
|
||
authenticatorData: response.authenticatorData
|
||
? bufToB64url(response.authenticatorData)
|
||
: undefined,
|
||
signature: response.signature ? bufToB64url(response.signature) : undefined,
|
||
userHandle: response.userHandle ? bufToB64url(response.userHandle) : undefined,
|
||
},
|
||
};
|
||
if (typeof response.getTransports === "function") {
|
||
out.transports = response.getTransports();
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function stopQrPoll() {
|
||
if (qrPollTimer) {
|
||
clearInterval(qrPollTimer);
|
||
qrPollTimer = null;
|
||
}
|
||
$("#qr-auth-box")?.classList.add("hidden");
|
||
}
|
||
|
||
function toast(msg) {
|
||
const el = $("#toast");
|
||
el.textContent = msg.replace(/<[^>]+>/g, " ");
|
||
el.classList.remove("hidden");
|
||
clearTimeout(toast._t);
|
||
toast._t = setTimeout(() => el.classList.add("hidden"), 3200);
|
||
}
|
||
|
||
async function copyText(text) {
|
||
const value = String(text || "");
|
||
if (!value) return false;
|
||
|
||
// Clipboard API часто падает в Telegram WebView / при открытом <dialog>
|
||
try {
|
||
if (navigator.clipboard?.writeText && window.isSecureContext && document.hasFocus()) {
|
||
await navigator.clipboard.writeText(value);
|
||
return true;
|
||
}
|
||
} catch {
|
||
/* fallback */
|
||
}
|
||
|
||
try {
|
||
const ta = document.createElement("textarea");
|
||
ta.value = value;
|
||
ta.setAttribute("readonly", "");
|
||
ta.style.cssText =
|
||
"position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;outline:none;box-shadow:none;background:transparent;opacity:0;";
|
||
document.body.appendChild(ta);
|
||
ta.focus({ preventScroll: true });
|
||
ta.select();
|
||
ta.setSelectionRange(0, value.length);
|
||
const ok = document.execCommand("copy");
|
||
document.body.removeChild(ta);
|
||
if (ok) return true;
|
||
} catch {
|
||
/* fallback */
|
||
}
|
||
|
||
try {
|
||
if (navigator.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(value);
|
||
return true;
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return false;
|
||
}
|
||
|
||
async function api(path, options = {}) {
|
||
const headers = {
|
||
"Content-Type": "application/json",
|
||
...(options.headers || {}),
|
||
};
|
||
if (initData) {
|
||
headers["X-Telegram-Init-Data"] = initData;
|
||
}
|
||
const token = getStoredToken();
|
||
if (token) {
|
||
headers.Authorization = `Bearer ${token}`;
|
||
}
|
||
const res = await fetch(path, {
|
||
...options,
|
||
headers,
|
||
credentials: "same-origin",
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok || data.ok === false) {
|
||
const err = data.message || data.error || `HTTP ${res.status}`;
|
||
throw new Error(String(err).replace(/<[^>]+>/g, " "));
|
||
}
|
||
if (data.token) setStoredToken(data.token);
|
||
return data;
|
||
}
|
||
|
||
function isTelegramWebApp() {
|
||
return Boolean(tg && (initData || tg.initDataUnsafe?.user));
|
||
}
|
||
|
||
function canOpenStarsInvoice() {
|
||
return Boolean(tg && typeof tg.openInvoice === "function" && isTelegramWebApp());
|
||
}
|
||
|
||
async function loadStarsPacks() {
|
||
const card = $("#stars-topup-card");
|
||
const box = $("#stars-packs");
|
||
const webOnly = $("#stars-web-only");
|
||
const hint = $("#stars-topup-hint");
|
||
if (!card || !box) return;
|
||
|
||
if (!state.starsEnabled) {
|
||
card.classList.add("hidden");
|
||
return;
|
||
}
|
||
card.classList.remove("hidden");
|
||
|
||
if (!state.authed) {
|
||
box.innerHTML = "";
|
||
if (webOnly) {
|
||
webOnly.textContent = "Войдите через Telegram, чтобы пополнить Stars.";
|
||
webOnly.classList.remove("hidden");
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (state.me?.user?.telegram_id != null && Number(state.me.user.telegram_id) <= 0) {
|
||
box.innerHTML = "";
|
||
if (webOnly) {
|
||
webOnly.textContent =
|
||
"Stars только для Telegram-аккаунта. Войдите через Telegram Login или Mini App.";
|
||
webOnly.classList.remove("hidden");
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!canOpenStarsInvoice()) {
|
||
box.innerHTML = "";
|
||
if (webOnly) webOnly.classList.remove("hidden");
|
||
if (hint) {
|
||
hint.textContent = `Курс 1 ⭐ ≈ ${state.starsRubPerStar} ₽ · откройте кабинет из Telegram`;
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (webOnly) webOnly.classList.add("hidden");
|
||
try {
|
||
const data = await api("/api/stars/packs");
|
||
box.innerHTML = "";
|
||
if (hint && data.note) hint.textContent = data.note;
|
||
(data.packs || []).forEach((p) => {
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "btn secondary full";
|
||
btn.textContent = `⭐ ${p.title}`;
|
||
btn.addEventListener("click", () => payStars(p.amount_rub, btn));
|
||
box.appendChild(btn);
|
||
});
|
||
} catch (e) {
|
||
box.innerHTML = `<p class="muted small">${e.message || "Не удалось загрузить пакеты"}</p>`;
|
||
}
|
||
}
|
||
|
||
async function payStars(amountRub, btn) {
|
||
if (!state.authed) {
|
||
toast("Сначала войдите");
|
||
showAuth(true);
|
||
return;
|
||
}
|
||
if (!canOpenStarsInvoice()) {
|
||
toast("Откройте кабинет из Telegram");
|
||
return;
|
||
}
|
||
const old = btn.textContent;
|
||
btn.disabled = true;
|
||
btn.textContent = "…";
|
||
try {
|
||
const data = await api("/api/stars/invoice", {
|
||
method: "POST",
|
||
body: JSON.stringify({ amount: Number(amountRub) }),
|
||
});
|
||
const link = data.invoice_link;
|
||
if (!link) throw new Error("Нет ссылки на оплату");
|
||
|
||
tg.openInvoice(link, async (status) => {
|
||
if (status === "paid") {
|
||
toast("Баланс пополнен через Stars");
|
||
if (tg.HapticFeedback) tg.HapticFeedback.notificationOccurred("success");
|
||
try {
|
||
await loadMe();
|
||
renderTariffs();
|
||
loadTransactions(1);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
} else if (status === "cancelled") {
|
||
toast("Оплата отменена");
|
||
} else if (status === "failed") {
|
||
toast("Оплата не удалась");
|
||
if (tg.HapticFeedback) tg.HapticFeedback.notificationOccurred("error");
|
||
}
|
||
});
|
||
} catch (e) {
|
||
toast(e.message || "Ошибка Stars");
|
||
if (tg?.HapticFeedback) tg.HapticFeedback.notificationOccurred("error");
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = old;
|
||
}
|
||
}
|
||
|
||
function switchTab(name) {
|
||
const valid = new Set([
|
||
"home",
|
||
"balance",
|
||
"configs",
|
||
"extras",
|
||
"account",
|
||
"tariffs",
|
||
"clients",
|
||
"servers",
|
||
"faq",
|
||
"tickets",
|
||
]);
|
||
const tab = valid.has(name) ? name : "home";
|
||
document.querySelectorAll(".tab").forEach((b) => {
|
||
const on = b.dataset.tab === tab;
|
||
b.classList.toggle("active", on);
|
||
if (on && typeof b.scrollIntoView === "function") {
|
||
try {
|
||
b.scrollIntoView({ behavior: "smooth", inline: "center", block: "nearest" });
|
||
} catch (_) {}
|
||
}
|
||
});
|
||
document.querySelectorAll(".panel").forEach((p) => {
|
||
p.classList.toggle("active", p.id === `tab-${tab}`);
|
||
});
|
||
if (tab === "servers" && !$("#servers-list").children.length) {
|
||
loadServers();
|
||
}
|
||
if (tab === "faq" && !$("#faq-list").children.length) {
|
||
loadFaq();
|
||
}
|
||
if (tab === "tickets") {
|
||
loadTickets();
|
||
}
|
||
if (tab === "balance") {
|
||
loadTransactions();
|
||
}
|
||
if (tab === "configs") {
|
||
loadWgConfigs();
|
||
loadAwgConfigs();
|
||
}
|
||
if (tab === "extras") {
|
||
loadTrafficPacks();
|
||
loadDevicePacks();
|
||
}
|
||
try {
|
||
localStorage.setItem("cabinet_tab", tab);
|
||
if (location.hash !== `#${tab}`) {
|
||
history.replaceState(null, "", `#${tab}`);
|
||
}
|
||
} catch (_) {}
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
}
|
||
|
||
document.querySelectorAll(".tab").forEach((btn) => {
|
||
btn.addEventListener("click", () => switchTab(btn.dataset.tab));
|
||
});
|
||
|
||
document.querySelectorAll(".auth-tab").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
setAuthMode(btn.dataset.auth === "register" ? "register" : "login");
|
||
});
|
||
});
|
||
|
||
function setAuthMode(mode) {
|
||
const m = mode || "login";
|
||
document.querySelectorAll(".auth-tab").forEach((b) => {
|
||
b.classList.toggle("active", b.dataset.auth === (m === "register" ? "register" : "login"));
|
||
});
|
||
$("#form-login")?.classList.toggle("hidden", m !== "login");
|
||
$("#form-register")?.classList.toggle("hidden", m !== "register");
|
||
$("#form-forgot")?.classList.toggle("hidden", m !== "forgot");
|
||
$("#form-reset")?.classList.toggle("hidden", m !== "reset");
|
||
const welcome = $(".auth-welcome");
|
||
const sub = $(".auth-sub");
|
||
if (welcome && sub) {
|
||
if (m === "register") {
|
||
welcome.textContent = "Добро пожаловать";
|
||
sub.textContent = "Создайте аккаунт — дальше поможем подключить VPN";
|
||
} else if (m === "forgot") {
|
||
welcome.textContent = "Сброс пароля";
|
||
sub.textContent = "Письмо со ссылкой придёт на email аккаунта";
|
||
} else if (m === "reset") {
|
||
welcome.textContent = "Новый пароль";
|
||
sub.textContent = "Задайте пароль и войдите в кабинет";
|
||
} else {
|
||
welcome.textContent = "С возвращением";
|
||
sub.textContent = "Войдите, чтобы управлять VPN, балансом и конфигами";
|
||
}
|
||
}
|
||
if (m === "forgot") {
|
||
const st = $("#forgot-status");
|
||
if (st) st.textContent = "";
|
||
setTimeout(() => $("#form-forgot input[name=email]")?.focus(), 50);
|
||
}
|
||
}
|
||
|
||
function showAuth(show) {
|
||
$("#auth-panel").classList.toggle("hidden", !show);
|
||
$("#guest-banner").classList.toggle("hidden", !show);
|
||
$("#cabinet").classList.toggle("hidden", show);
|
||
$("#security-card")?.classList.toggle("hidden", show);
|
||
$("#btn-logout").classList.toggle("hidden", show || !!initData);
|
||
if (show && !state.me) {
|
||
$("#user-name").textContent = "Вход в кабинет";
|
||
setBalancePill("—");
|
||
$("#onboarding-card")?.classList.remove("hidden");
|
||
$("#howto-card")?.classList.remove("hidden");
|
||
const hello = $("#home-hello");
|
||
const helloSub = $("#home-hello-sub");
|
||
if (hello) hello.textContent = "Привет!";
|
||
if (helloSub) helloSub.textContent = "Войдите или зарегистрируйтесь — поможем подключить VPN";
|
||
setSubBadge("off", "Нет тарифа");
|
||
const statusEl = $("#sub-status");
|
||
if (statusEl) {
|
||
statusEl.classList.remove("status-ok", "status-warn");
|
||
statusEl.classList.add("status-off");
|
||
statusEl.textContent = "После входа здесь появится статус подписки";
|
||
}
|
||
}
|
||
loadStarsPacks();
|
||
}
|
||
|
||
const TX_PAGE_SIZE = 10;
|
||
let txPage = 1;
|
||
|
||
function syncTxPager(page, pages, total) {
|
||
const pager = $("#transactions-pager");
|
||
const label = $("#tx-page-label");
|
||
const prev = $("#tx-prev");
|
||
const next = $("#tx-next");
|
||
const meta = $("#balance-history-meta");
|
||
if (meta) {
|
||
meta.textContent = total
|
||
? `${total} операций · по ${TX_PAGE_SIZE} на странице`
|
||
: "Последние операции";
|
||
}
|
||
if (!pager || !label || !prev || !next) return;
|
||
if (!total || pages <= 1) {
|
||
pager.classList.add("hidden");
|
||
return;
|
||
}
|
||
pager.classList.remove("hidden");
|
||
label.textContent = `${page} / ${pages}`;
|
||
prev.disabled = page <= 1;
|
||
next.disabled = page >= pages;
|
||
}
|
||
|
||
async function loadTransactions(page = txPage) {
|
||
const list = $("#transactions-list");
|
||
const card = $("#balance-history-card");
|
||
if (!list || !card) return;
|
||
if (!state.authed) {
|
||
card.classList.add("hidden");
|
||
$("#transactions-pager")?.classList.add("hidden");
|
||
return;
|
||
}
|
||
txPage = Math.max(1, Number(page) || 1);
|
||
try {
|
||
const data = await api(`/api/transactions?limit=${TX_PAGE_SIZE}&page=${txPage}`);
|
||
list.innerHTML = "";
|
||
const items = data.items || [];
|
||
const total = Number(data.total || 0);
|
||
const pages = Math.max(1, Number(data.pages || 1));
|
||
txPage = Math.min(Math.max(1, Number(data.page || txPage)), pages);
|
||
if (!items.length) {
|
||
list.innerHTML = '<li class="muted small">Операций пока нет</li>';
|
||
syncTxPager(1, 1, 0);
|
||
card.classList.remove("hidden");
|
||
return;
|
||
}
|
||
items.forEach((tx) => {
|
||
const li = document.createElement("li");
|
||
const plus = tx.amount > 0;
|
||
const date = tx.created_at
|
||
? new Date(tx.created_at).toLocaleString("ru-RU", {
|
||
day: "2-digit",
|
||
month: "short",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
})
|
||
: "";
|
||
li.innerHTML = `
|
||
<div class="tx-main">
|
||
<div class="tx-label">${tx.label || "Операция"}</div>
|
||
<div class="tx-meta">${date}</div>
|
||
</div>
|
||
<span class="tx-amount ${plus ? "plus" : "minus"}">${tx.amount_label || ""}</span>
|
||
`;
|
||
list.appendChild(li);
|
||
});
|
||
syncTxPager(txPage, pages, total);
|
||
card.classList.remove("hidden");
|
||
} catch {
|
||
list.innerHTML = '<li class="muted small">Не удалось загрузить историю</li>';
|
||
syncTxPager(1, 1, 0);
|
||
card.classList.remove("hidden");
|
||
}
|
||
}
|
||
|
||
function setSubBadge(kind, label) {
|
||
const badge = $("#sub-badge");
|
||
if (!badge) return;
|
||
badge.className = `status-badge status-badge--${kind}`;
|
||
badge.textContent = label;
|
||
}
|
||
|
||
function syncAutoRenewUi() {
|
||
const toggle = $("#auto-renew-toggle");
|
||
const hint = $("#auto-renew-hint");
|
||
const box = $("#auto-renew-box");
|
||
if (!toggle || !state.me?.user) return;
|
||
const on = !!state.me.user.auto_renew;
|
||
toggle.checked = on;
|
||
if (box) box.classList.toggle("is-off", !on);
|
||
if (hint) {
|
||
hint.textContent = on
|
||
? "При достаточном балансе тариф продлится сам"
|
||
: "Выключено — продлевайте тариф вручную";
|
||
}
|
||
}
|
||
|
||
function renderHome() {
|
||
const me = state.me;
|
||
if (!me) return;
|
||
const displayName = me.user.name || me.user.login || "друг";
|
||
$("#user-name").textContent = me.user.name || me.user.login || "Пользователь";
|
||
setBalancePill(me.balance);
|
||
syncAutoRenewUi();
|
||
|
||
const hello = $("#home-hello");
|
||
const helloSub = $("#home-hello-sub");
|
||
if (hello) hello.textContent = `Привет, ${displayName}!`;
|
||
|
||
const sub = me.subscription;
|
||
const statusEl = $("#sub-status");
|
||
const wrap = $("#sub-url-wrap");
|
||
const renewBtn = $("#btn-renew-go");
|
||
const trafficBtn = $("#btn-traffic-go");
|
||
const devicesBtn = $("#btn-devices-go");
|
||
const wgBtn = $("#btn-wg-go");
|
||
const awgBtn = $("#btn-awg-go");
|
||
const trafficBox = $("#traffic-box");
|
||
const devicesBox = $("#devices-box");
|
||
const packsCard = $("#traffic-packs-card");
|
||
const devicePacksCard = $("#device-packs-card");
|
||
const wgCard = $("#wg-configs-card");
|
||
const awgCard = $("#awg-configs-card");
|
||
const extrasBox = $("#extras-billing-box");
|
||
const onboarding = $("#onboarding-card");
|
||
const howto = $("#howto-card");
|
||
const subCard = $("#sub-card");
|
||
|
||
if (!sub.has) {
|
||
if (helloSub) {
|
||
helloSub.textContent = "Давайте подключим VPN — это займёт пару минут";
|
||
}
|
||
onboarding?.classList.remove("hidden");
|
||
howto?.classList.remove("hidden");
|
||
subCard?.classList.add("card-dimmed");
|
||
setSubBadge("off", "Нет тарифа");
|
||
statusEl.classList.remove("status-ok", "status-warn");
|
||
statusEl.classList.add("status-off");
|
||
statusEl.textContent = "Тарифа пока нет. База: 1000 ГБ и 10 устройств.";
|
||
wrap.classList.add("hidden");
|
||
renewBtn.classList.add("hidden");
|
||
trafficBtn.classList.add("hidden");
|
||
devicesBtn.classList.add("hidden");
|
||
if (wgBtn) wgBtn.classList.add("hidden");
|
||
if (awgBtn) awgBtn.classList.add("hidden");
|
||
trafficBox.classList.add("hidden");
|
||
devicesBox.classList.add("hidden");
|
||
packsCard.classList.add("hidden");
|
||
devicePacksCard.classList.add("hidden");
|
||
if (wgCard) wgCard.classList.add("hidden");
|
||
if (awgCard) awgCard.classList.add("hidden");
|
||
if (extrasBox) extrasBox.classList.add("hidden");
|
||
$("#balance-history-card")?.classList.add("hidden");
|
||
return;
|
||
}
|
||
|
||
if (helloSub) {
|
||
helloSub.textContent = "Всё под контролем — ссылка, трафик и устройства здесь";
|
||
}
|
||
onboarding?.classList.add("hidden");
|
||
howto?.classList.add("hidden");
|
||
subCard?.classList.remove("card-dimmed");
|
||
|
||
statusEl.classList.remove("status-ok", "status-warn", "status-off");
|
||
|
||
const exp = sub.expire_at
|
||
? new Date(sub.expire_at).toLocaleString("ru-RU")
|
||
: "—";
|
||
const over = !!sub.devices?.over_limit;
|
||
if (over) {
|
||
setSubBadge("warn", "Лимит устройств");
|
||
statusEl.classList.add("status-warn");
|
||
statusEl.innerHTML = `Подписка на паузе: слишком много устройств · до <b>${exp}</b>`;
|
||
} else {
|
||
setSubBadge("ok", "Активна");
|
||
statusEl.classList.add("status-ok");
|
||
statusEl.innerHTML = `Работает до <b>${exp}</b>`;
|
||
}
|
||
if (sub.url) {
|
||
$("#sub-url").textContent = sub.url;
|
||
wrap.classList.remove("hidden");
|
||
}
|
||
renewBtn.classList.remove("hidden");
|
||
trafficBtn.classList.remove("hidden");
|
||
devicesBtn.classList.remove("hidden");
|
||
loadWgConfigs();
|
||
loadAwgConfigs();
|
||
|
||
const tr = sub.traffic;
|
||
if (tr) {
|
||
trafficBox.classList.remove("hidden");
|
||
$("#traffic-label").textContent = `${tr.used_label} / ${tr.limit_label}`;
|
||
$("#traffic-left").textContent = `Осталось ${tr.left_label} · ${tr.percent}%`;
|
||
const fill = $("#traffic-fill");
|
||
fill.style.width = `${Math.min(100, tr.percent)}%`;
|
||
fill.classList.toggle("warn", tr.percent >= 70 && tr.percent < 90);
|
||
fill.classList.toggle("danger", tr.percent >= 90);
|
||
} else {
|
||
trafficBox.classList.add("hidden");
|
||
}
|
||
|
||
const dev = sub.devices;
|
||
if (dev) {
|
||
devicesBox.classList.remove("hidden");
|
||
$("#devices-label").textContent = `${dev.used} / ${dev.allowed}`;
|
||
const warn = $("#devices-warn");
|
||
if (dev.over_limit) {
|
||
warn.classList.remove("hidden");
|
||
warn.textContent = `Лишних устройств: +${dev.excess}. Оплатите слот (${Math.round(dev.excess_price)} ₽/мес) или отвяжите лишние.`;
|
||
} else {
|
||
warn.classList.add("hidden");
|
||
}
|
||
} else {
|
||
devicesBox.classList.add("hidden");
|
||
}
|
||
|
||
const extras = sub.extras;
|
||
if (extrasBox && extras?.has_subscription) {
|
||
extrasBox.classList.remove("hidden");
|
||
extrasBox.classList.toggle("suspended", !extras.active);
|
||
$("#extras-fee-label").textContent = extras.monthly_fee_label || "—";
|
||
const parts = [];
|
||
if (extras.extra_traffic_packs) {
|
||
parts.push(`+${extras.extra_traffic_packs}×500 ГБ`);
|
||
}
|
||
if (extras.extra_devices) {
|
||
parts.push(`+${extras.extra_devices} устр.`);
|
||
}
|
||
let line = parts.join(" · ") || "Доп. опции";
|
||
if (extras.next_billing_label) {
|
||
line += extras.active
|
||
? ` · следующее списание ${extras.next_billing_label}`
|
||
: " · на паузе — пополните баланс";
|
||
}
|
||
$("#extras-billing-text").textContent = line;
|
||
} else if (extrasBox) {
|
||
extrasBox.classList.add("hidden");
|
||
}
|
||
loadTransactions();
|
||
}
|
||
|
||
async function loadTrafficPacks() {
|
||
const box = $("#traffic-packs");
|
||
const card = $("#traffic-packs-card");
|
||
try {
|
||
const data = await api("/api/traffic/packs");
|
||
box.innerHTML = "";
|
||
(data.packs || []).forEach((p) => {
|
||
const el = document.createElement("div");
|
||
el.className = "actions";
|
||
el.innerHTML = `
|
||
<button class="btn primary full-span" data-pack="${p.packs}">
|
||
📈 ${p.title} · ${p.price_label}
|
||
</button>
|
||
`;
|
||
box.appendChild(el);
|
||
});
|
||
box.querySelectorAll("[data-pack]").forEach((btn) => {
|
||
btn.addEventListener("click", () => buyTraffic(btn.dataset.pack, btn));
|
||
});
|
||
card.classList.remove("hidden");
|
||
} catch (e) {
|
||
toast(e.message || "Не удалось загрузить пакеты");
|
||
}
|
||
}
|
||
|
||
async function loadDevicePacks() {
|
||
const box = $("#device-packs");
|
||
const card = $("#device-packs-card");
|
||
try {
|
||
const data = await api("/api/devices/packs");
|
||
box.innerHTML = "";
|
||
const excess = state.me?.subscription?.devices?.excess || 0;
|
||
if (excess > 0) {
|
||
const price = Math.round((state.me.subscription.devices.excess_price || 0));
|
||
const el = document.createElement("div");
|
||
el.className = "actions";
|
||
el.innerHTML = `
|
||
<button class="btn primary full-span" data-slots="${excess}">
|
||
⚠ Оплатить +${excess} устр. · ${price} ₽
|
||
</button>
|
||
`;
|
||
box.appendChild(el);
|
||
}
|
||
(data.packs || []).forEach((p) => {
|
||
const el = document.createElement("div");
|
||
el.className = "actions";
|
||
el.innerHTML = `
|
||
<button class="btn secondary full-span" data-slots="${p.slots}">
|
||
➕ ${p.title} · ${p.price_label}
|
||
</button>
|
||
`;
|
||
box.appendChild(el);
|
||
});
|
||
box.querySelectorAll("[data-slots]").forEach((btn) => {
|
||
btn.addEventListener("click", () => buyDevices(btn.dataset.slots, btn));
|
||
});
|
||
card.classList.remove("hidden");
|
||
} catch (e) {
|
||
toast(e.message || "Не удалось загрузить пакеты устройств");
|
||
}
|
||
}
|
||
|
||
async function buyTraffic(packs, btn) {
|
||
if (!state.authed) {
|
||
toast("Сначала войдите в аккаунт");
|
||
showAuth(true);
|
||
return;
|
||
}
|
||
const old = btn.textContent;
|
||
btn.disabled = true;
|
||
btn.textContent = "…";
|
||
try {
|
||
await api("/api/traffic/buy", {
|
||
method: "POST",
|
||
body: JSON.stringify({ packs: Number(packs) }),
|
||
});
|
||
toast("Трафик увеличен");
|
||
if (tg?.HapticFeedback) tg.HapticFeedback.notificationOccurred("success");
|
||
await loadMe();
|
||
} catch (e) {
|
||
toast(e.message || "Ошибка покупки трафика");
|
||
if (tg?.HapticFeedback) tg.HapticFeedback.notificationOccurred("error");
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = old;
|
||
}
|
||
}
|
||
|
||
async function buyDevices(slots, btn) {
|
||
if (!state.authed) {
|
||
toast("Сначала войдите в аккаунт");
|
||
showAuth(true);
|
||
return;
|
||
}
|
||
const old = btn.textContent;
|
||
btn.disabled = true;
|
||
btn.textContent = "…";
|
||
try {
|
||
await api("/api/devices/buy", {
|
||
method: "POST",
|
||
body: JSON.stringify({ slots: Number(slots) }),
|
||
});
|
||
toast("Лимит устройств увеличен");
|
||
if (tg?.HapticFeedback) tg.HapticFeedback.notificationOccurred("success");
|
||
await loadMe();
|
||
loadDevicePacks();
|
||
} catch (e) {
|
||
toast(e.message || "Ошибка покупки устройств");
|
||
if (tg?.HapticFeedback) tg.HapticFeedback.notificationOccurred("error");
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = old;
|
||
}
|
||
}
|
||
|
||
function renderTariffs() {
|
||
const box = $("#tariffs-list");
|
||
box.innerHTML = "";
|
||
const hasSub = !!state.me?.subscription?.has;
|
||
|
||
if (!state.tariffs?.length) {
|
||
box.innerHTML = `
|
||
<article class="card tip empty-state">
|
||
<p class="empty-title">Тарифы скоро появятся</p>
|
||
<p class="muted small">Обновите страницу или загляните чуть позже</p>
|
||
</article>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
const promoWrap = document.createElement("div");
|
||
promoWrap.className = "auth-form";
|
||
promoWrap.innerHTML = `
|
||
<label>Промокод
|
||
<input id="promo-code-input" type="text" maxlength="40" placeholder="необязательно" autocomplete="off" />
|
||
</label>`;
|
||
box.appendChild(promoWrap);
|
||
|
||
state.tariffs.forEach((t) => {
|
||
const traffic =
|
||
t.traffic_gb > 0 ? `${t.traffic_gb} ГБ` : "1000 ГБ";
|
||
const el = document.createElement("article");
|
||
el.className = "tariff";
|
||
el.innerHTML = `
|
||
<h3>${t.title}</h3>
|
||
<p class="muted small">${t.description || ""}</p>
|
||
<div class="tariff-meta">
|
||
<span class="chip">${t.days} дн.</span>
|
||
<span class="chip">${traffic}</span>
|
||
<span class="chip">${t.device_limit} устр.</span>
|
||
</div>
|
||
<p class="price">${t.price_label}</p>
|
||
<div class="actions">
|
||
<button class="btn primary" data-buy="${t.id}"><i class="fa-solid fa-cart-shopping"></i> ${hasSub ? "Купить" : "Начать"}</button>
|
||
${
|
||
hasSub
|
||
? `<button class="btn secondary" data-renew="${t.id}"><i class="fa-solid fa-rotate"></i> Продлить</button>`
|
||
: `<button class="btn ghost" disabled title="Сначала купите тариф"><i class="fa-solid fa-rotate"></i> Продлить</button>`
|
||
}
|
||
</div>
|
||
`;
|
||
box.appendChild(el);
|
||
});
|
||
|
||
box.querySelectorAll("[data-buy]").forEach((btn) => {
|
||
btn.addEventListener("click", () => buy(btn.dataset.buy, false, btn));
|
||
});
|
||
box.querySelectorAll("[data-renew]").forEach((btn) => {
|
||
btn.addEventListener("click", () => buy(btn.dataset.renew, true, btn));
|
||
});
|
||
}
|
||
|
||
async function buy(tariffId, renew, btn) {
|
||
if (!state.authed) {
|
||
toast("Сначала войдите в аккаунт");
|
||
showAuth(true);
|
||
return;
|
||
}
|
||
const old = btn.textContent;
|
||
btn.disabled = true;
|
||
btn.textContent = "…";
|
||
try {
|
||
const data = await api("/api/buy", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
tariff_id: tariffId,
|
||
renew,
|
||
promo_code: ($("#promo-code-input")?.value || "").trim() || undefined,
|
||
}),
|
||
});
|
||
toast(renew ? "Подписка продлена" : "Тариф активирован");
|
||
if (tg?.HapticFeedback) tg.HapticFeedback.notificationOccurred("success");
|
||
await loadMe();
|
||
renderTariffs();
|
||
if (data.subscription_url) switchTab("home");
|
||
} catch (e) {
|
||
toast(e.message || "Ошибка оплаты");
|
||
if (tg?.HapticFeedback) tg.HapticFeedback.notificationOccurred("error");
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = old;
|
||
}
|
||
}
|
||
|
||
function renderClients(clients) {
|
||
const box = $("#clients-list");
|
||
box.innerHTML = "";
|
||
if (!clients?.length) {
|
||
box.innerHTML = `
|
||
<article class="card tip empty-state">
|
||
<p class="empty-title">Клиенты пока не загрузились</p>
|
||
<p class="muted small">Загляните позже или напишите в тикеты — поможем подобрать приложение</p>
|
||
</article>
|
||
`;
|
||
return;
|
||
}
|
||
clients.forEach((c) => {
|
||
const el = document.createElement("article");
|
||
el.className = "client";
|
||
const badges = [
|
||
c.featured ? '<span class="chip accent">рекомендуем</span>' : "",
|
||
c.hwid ? '<span class="chip">HWID</span>' : "",
|
||
`<span class="chip">${c.core}</span>`,
|
||
]
|
||
.filter(Boolean)
|
||
.join("");
|
||
el.innerHTML = `
|
||
<h3>${c.name}</h3>
|
||
<p class="muted small">${c.description}</p>
|
||
<div class="client-meta">${badges}</div>
|
||
<div class="client-links">
|
||
${c.links
|
||
.map((l) => `<a href="${l.url}" target="_blank" rel="noopener">${l.title}</a>`)
|
||
.join("")}
|
||
</div>
|
||
`;
|
||
box.appendChild(el);
|
||
});
|
||
}
|
||
|
||
function stripHtml(html) {
|
||
return String(html || "")
|
||
.replace(/<br\s*\/?>/gi, "\n")
|
||
.replace(/<\/p>/gi, "\n")
|
||
.replace(/<[^>]+>/g, "")
|
||
.replace(/ /g, " ")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/&/g, "&")
|
||
.trim();
|
||
}
|
||
|
||
async function loadFaq() {
|
||
const box = $("#faq-list");
|
||
box.innerHTML = '<p class="muted small">Загрузка…</p>';
|
||
try {
|
||
const data = await api("/api/faq");
|
||
box.innerHTML = "";
|
||
(data.items || []).forEach((item) => {
|
||
const el = document.createElement("details");
|
||
el.className = "faq-item";
|
||
el.innerHTML = `
|
||
<summary>${item.question}</summary>
|
||
<div class="faq-answer">${stripHtml(item.answer)}</div>
|
||
`;
|
||
box.appendChild(el);
|
||
});
|
||
} catch (e) {
|
||
box.innerHTML = `<p class="muted">${e.message || "Ошибка"}</p>`;
|
||
}
|
||
}
|
||
|
||
let ticketsMeta = { max_open: 3, open_count: 0 };
|
||
let activeTicketId = null;
|
||
|
||
function escapeHtml(s) {
|
||
return String(s)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function fmtTicketDate(iso) {
|
||
if (!iso) return "—";
|
||
try {
|
||
return new Date(iso).toLocaleString("ru-RU", {
|
||
day: "2-digit",
|
||
month: "short",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|
||
|
||
function showTicketList() {
|
||
$("#ticket-thread").classList.add("hidden");
|
||
$("#tickets-list").classList.remove("hidden");
|
||
$("#btn-ticket-new").classList.remove("hidden");
|
||
activeTicketId = null;
|
||
}
|
||
|
||
async function loadTickets() {
|
||
if (!state.me) {
|
||
$("#tickets-list").innerHTML =
|
||
'<p class="muted small">Войдите, чтобы создавать тикеты</p>';
|
||
$("#ticket-create-form").classList.add("hidden");
|
||
$("#ticket-thread").classList.add("hidden");
|
||
return;
|
||
}
|
||
showTicketList();
|
||
const box = $("#tickets-list");
|
||
box.innerHTML = '<p class="muted small">Загрузка…</p>';
|
||
try {
|
||
const data = await api("/api/tickets");
|
||
ticketsMeta = {
|
||
max_open: data.max_open || 3,
|
||
open_count: data.open_count || 0,
|
||
};
|
||
$("#tickets-lead").textContent =
|
||
`Открытых: ${ticketsMeta.open_count} / ${ticketsMeta.max_open}`;
|
||
box.innerHTML = "";
|
||
const tickets = data.tickets || [];
|
||
if (!tickets.length) {
|
||
box.innerHTML = '<p class="muted small">Тикетов пока нет</p>';
|
||
return;
|
||
}
|
||
tickets.forEach((t) => {
|
||
const el = document.createElement("button");
|
||
el.type = "button";
|
||
el.className = "ticket-item";
|
||
el.dataset.id = t.id;
|
||
el.innerHTML = `
|
||
<div class="ticket-item-main">
|
||
<strong>#${t.id} · ${escapeHtml(t.subject)}</strong>
|
||
<span class="muted small">${fmtTicketDate(t.updated_at)}</span>
|
||
</div>
|
||
<span class="ticket-badge ticket-badge--${escapeHtml(t.status)}">${escapeHtml(t.status_label || t.status)}</span>
|
||
`;
|
||
box.appendChild(el);
|
||
});
|
||
} catch (e) {
|
||
box.innerHTML = `<p class="muted">${e.message || "Ошибка"}</p>`;
|
||
}
|
||
}
|
||
|
||
function renderTicketThread(data) {
|
||
const t = data.ticket || {};
|
||
activeTicketId = t.id;
|
||
$("#tickets-list").classList.add("hidden");
|
||
$("#ticket-create-form").classList.add("hidden");
|
||
$("#btn-ticket-new").classList.add("hidden");
|
||
const thread = $("#ticket-thread");
|
||
thread.classList.remove("hidden");
|
||
$("#ticket-thread-title").textContent = `#${t.id} · ${t.subject || ""}`;
|
||
$("#ticket-thread-meta").textContent =
|
||
`${t.status_label || t.status} · обновлён ${fmtTicketDate(t.updated_at)}`;
|
||
const box = $("#ticket-messages");
|
||
box.innerHTML = "";
|
||
(data.messages || []).forEach((m) => {
|
||
const el = document.createElement("div");
|
||
el.className = `ticket-msg ticket-msg--${m.author_role === "admin" ? "admin" : m.author_role === "ai" ? "ai" : "user"}`;
|
||
const who =
|
||
m.author_role === "admin" ? "Поддержка" : m.author_role === "ai" ? "ИИ-поддержка" : "Вы";
|
||
el.innerHTML = `
|
||
<div class="ticket-msg-meta"><strong>${who}</strong> · ${fmtTicketDate(m.created_at)}</div>
|
||
<div class="ticket-msg-body">${escapeHtml(m.body)}</div>
|
||
`;
|
||
box.appendChild(el);
|
||
});
|
||
const closed = t.status === "closed";
|
||
$("#ticket-reply-form").classList.toggle("hidden", closed);
|
||
$("#btn-ticket-close").classList.toggle("hidden", closed);
|
||
box.scrollTop = box.scrollHeight;
|
||
}
|
||
|
||
async function openTicket(id) {
|
||
try {
|
||
const data = await api(`/api/tickets/${id}`);
|
||
renderTicketThread(data);
|
||
} catch (e) {
|
||
toast(e.message || "Ошибка");
|
||
}
|
||
}
|
||
|
||
$("#btn-ticket-new")?.addEventListener("click", () => {
|
||
if (!state.me) {
|
||
toast("Сначала войдите");
|
||
return;
|
||
}
|
||
if (ticketsMeta.open_count >= ticketsMeta.max_open) {
|
||
toast(`Лимит открытых тикетов: ${ticketsMeta.max_open}`);
|
||
return;
|
||
}
|
||
$("#ticket-create-form").classList.remove("hidden");
|
||
$("#ticket-thread").classList.add("hidden");
|
||
});
|
||
|
||
$("#btn-ticket-cancel")?.addEventListener("click", () => {
|
||
$("#ticket-create-form").classList.add("hidden");
|
||
$("#ticket-create-form").reset();
|
||
});
|
||
|
||
$("#ticket-create-form")?.addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
try {
|
||
const data = await api("/api/tickets", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
subject: fd.get("subject"),
|
||
body: fd.get("body"),
|
||
}),
|
||
});
|
||
e.target.reset();
|
||
$("#ticket-create-form").classList.add("hidden");
|
||
toast(data.ai?.action === "answer" ? "Тикет создан · ИИ ответил" : "Тикет создан");
|
||
if (data.messages && data.ticket?.id) {
|
||
renderTicketThread(data);
|
||
} else if (data.ticket?.id) {
|
||
await openTicket(data.ticket.id);
|
||
} else {
|
||
await loadTickets();
|
||
}
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
$("#tickets-list")?.addEventListener("click", (e) => {
|
||
const btn = e.target.closest(".ticket-item");
|
||
if (!btn) return;
|
||
openTicket(btn.dataset.id);
|
||
});
|
||
|
||
$("#btn-ticket-back")?.addEventListener("click", () => {
|
||
loadTickets();
|
||
});
|
||
|
||
$("#ticket-reply-form")?.addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
if (!activeTicketId) return;
|
||
const fd = new FormData(e.target);
|
||
try {
|
||
const data = await api(`/api/tickets/${activeTicketId}/messages`, {
|
||
method: "POST",
|
||
body: JSON.stringify({ body: fd.get("body") }),
|
||
});
|
||
e.target.reset();
|
||
renderTicketThread(data);
|
||
toast("Отправлено");
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
$("#btn-ticket-close")?.addEventListener("click", async () => {
|
||
if (!activeTicketId) return;
|
||
try {
|
||
const data = await api(`/api/tickets/${activeTicketId}/close`, {
|
||
method: "POST",
|
||
body: "{}",
|
||
});
|
||
renderTicketThread(data);
|
||
toast("Тикет закрыт");
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
async function loadServers() {
|
||
const summary = $("#servers-summary");
|
||
const box = $("#servers-list");
|
||
summary.textContent = "Проверяю…";
|
||
box.innerHTML = "";
|
||
try {
|
||
const data = await api("/api/servers");
|
||
summary.textContent = `Онлайн: ${data.online} / ${data.total}`;
|
||
data.nodes.forEach((n) => {
|
||
const el = document.createElement("article");
|
||
el.className = "server";
|
||
const ping = n.ping_ms != null ? `${Math.round(n.ping_ms)} ms` : "—";
|
||
el.innerHTML = `
|
||
<div>
|
||
<span class="dot ${n.online ? "on" : "off"}"></span>
|
||
<strong>${n.name}</strong>
|
||
<div class="muted small">${n.country} · ${n.label}</div>
|
||
</div>
|
||
<div class="muted small">${ping}</div>
|
||
`;
|
||
box.appendChild(el);
|
||
});
|
||
} catch (e) {
|
||
summary.textContent = e.message || "Ошибка";
|
||
}
|
||
}
|
||
|
||
async function loadMe() {
|
||
state.me = await api("/api/me");
|
||
state.authed = true;
|
||
if (state._forceResetUi && state._resetToken) {
|
||
// Ссылка сброса из письма — не прячем форму, пока не сохранят пароль
|
||
showAuth(true);
|
||
setAuthMode("reset");
|
||
} else {
|
||
showAuth(false);
|
||
}
|
||
renderHome();
|
||
loadStarsPacks();
|
||
renderWebTopup();
|
||
loadPasskeys().catch(() => {});
|
||
syncEmailUi();
|
||
loadReferral().catch(() => {});
|
||
loadNotifyPrefs().catch(() => {});
|
||
loadHwid().catch(() => {});
|
||
const fromHash = (location.hash || "").replace(/^#/, "");
|
||
let saved = "";
|
||
try {
|
||
saved = localStorage.getItem("cabinet_tab") || "";
|
||
} catch (_) {}
|
||
const initial = fromHash || saved || "home";
|
||
if (initial !== "home") switchTab(initial);
|
||
}
|
||
|
||
async function loadReferral() {
|
||
const el = $("#referral-link");
|
||
if (!el) return;
|
||
try {
|
||
const data = await api("/api/referral");
|
||
el.textContent = data.telegram_link || data.web_link || data.code || "—";
|
||
el.dataset.copy = data.telegram_link || data.web_link || data.code || "";
|
||
const hint = $("#referral-bonus-hint");
|
||
if (hint) {
|
||
hint.textContent = `Бонус: вам ${data.bonus_referee} ₽ · другу ${data.bonus_referrer} ₽ после первой покупки`;
|
||
}
|
||
} catch {
|
||
el.textContent = "—";
|
||
}
|
||
}
|
||
|
||
async function loadNotifyPrefs() {
|
||
try {
|
||
const data = await api("/api/account/notifications");
|
||
if ($("#notify-tg-toggle")) $("#notify-tg-toggle").checked = !!data.notify_telegram;
|
||
if ($("#notify-email-toggle")) $("#notify-email-toggle").checked = !!data.notify_email;
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
async function loadHwid() {
|
||
const list = $("#hwid-list");
|
||
if (!list) return;
|
||
try {
|
||
const data = await api("/api/devices/hwid");
|
||
const devices = data.devices || [];
|
||
if (!devices.length) {
|
||
list.innerHTML = "<li>Нет привязанных устройств</li>";
|
||
return;
|
||
}
|
||
list.innerHTML = "";
|
||
devices.forEach((d) => {
|
||
const li = document.createElement("li");
|
||
const title = [d.device_model, d.platform, d.os_version].filter(Boolean).join(" · ") || d.hwid;
|
||
li.innerHTML = `<span>${title}</span>`;
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "btn ghost small-btn";
|
||
btn.textContent = "Отвязать";
|
||
btn.addEventListener("click", async () => {
|
||
try {
|
||
await api("/api/devices/hwid/delete", {
|
||
method: "POST",
|
||
body: JSON.stringify({ hwid: d.hwid }),
|
||
});
|
||
toast("Устройство отвязано");
|
||
await loadHwid();
|
||
await loadMe();
|
||
} catch (err) {
|
||
toast(err.message || "Ошибка");
|
||
}
|
||
});
|
||
li.appendChild(btn);
|
||
list.appendChild(li);
|
||
});
|
||
} catch {
|
||
list.innerHTML = "<li>Не удалось загрузить</li>";
|
||
}
|
||
}
|
||
|
||
function syncEmailUi() {
|
||
const input = $("#account-email");
|
||
if (input) input.value = state.me?.user?.email || "";
|
||
}
|
||
|
||
async function loadPasskeys() {
|
||
const list = $("#passkey-list");
|
||
if (!list || !state.authed) return;
|
||
try {
|
||
const data = await api("/api/auth/passkey/list");
|
||
const items = data.passkeys || [];
|
||
if (!items.length) {
|
||
list.innerHTML = "<li>Passkey ещё не добавлен</li>";
|
||
return;
|
||
}
|
||
list.innerHTML = "";
|
||
items.forEach((p) => {
|
||
const li = document.createElement("li");
|
||
li.innerHTML = `<span>${p.device_name || "Passkey"}</span>`;
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "btn ghost small-btn";
|
||
btn.textContent = "Удалить";
|
||
btn.addEventListener("click", async () => {
|
||
try {
|
||
await api(`/api/auth/passkey/${encodeURIComponent(p.id)}`, {
|
||
method: "DELETE",
|
||
});
|
||
toast("Passkey удалён");
|
||
await loadPasskeys();
|
||
} catch (err) {
|
||
toast(err.message || "Ошибка");
|
||
}
|
||
});
|
||
li.appendChild(btn);
|
||
list.appendChild(li);
|
||
});
|
||
} catch {
|
||
list.innerHTML = "<li>Не удалось загрузить</li>";
|
||
}
|
||
}
|
||
|
||
function renderWebTopup() {
|
||
const card = $("#web-topup-card");
|
||
const presetsBox = $("#web-topup-presets");
|
||
const providersBox = $("#web-topup-providers");
|
||
const hint = $("#web-topup-hint");
|
||
const amountInput = $("#web-topup-amount");
|
||
if (!card || !providersBox) return;
|
||
|
||
const topup = state.topup;
|
||
if (!topup || !(topup.providers || []).length) {
|
||
card.classList.add("hidden");
|
||
return;
|
||
}
|
||
card.classList.remove("hidden");
|
||
|
||
const minA = topup.min_amount || 10;
|
||
const maxA = topup.max_amount || 3000;
|
||
if (hint) {
|
||
hint.textContent = `От ${minA} до ${maxA} ₽ за одну оплату · зачисление автоматически`;
|
||
}
|
||
if (amountInput) {
|
||
amountInput.min = String(minA);
|
||
amountInput.max = String(maxA);
|
||
}
|
||
|
||
if (presetsBox) {
|
||
presetsBox.innerHTML = "";
|
||
(topup.presets || []).forEach((n) => {
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "topup-preset-btn";
|
||
btn.textContent = `${n} ₽`;
|
||
btn.addEventListener("click", () => {
|
||
if (amountInput) amountInput.value = String(n);
|
||
presetsBox.querySelectorAll(".topup-preset-btn").forEach((b) => {
|
||
b.classList.toggle("active", b === btn);
|
||
});
|
||
});
|
||
presetsBox.appendChild(btn);
|
||
});
|
||
}
|
||
|
||
if (!state.authed) {
|
||
providersBox.innerHTML =
|
||
'<p class="muted small">Войдите, чтобы пополнить баланс картой, СБП или криптой.</p>';
|
||
return;
|
||
}
|
||
|
||
providersBox.innerHTML = "";
|
||
(topup.providers || []).forEach((p) => {
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "btn secondary full";
|
||
btn.innerHTML = `<strong>${escapeHtml(p.title)}</strong><small class="btn-sub">${escapeHtml(p.hint || "")}</small>`;
|
||
btn.addEventListener("click", () => createWebTopup(p.id, btn));
|
||
providersBox.appendChild(btn);
|
||
});
|
||
}
|
||
|
||
async function createWebTopup(provider, btn) {
|
||
if (!state.authed) {
|
||
toast("Сначала войдите");
|
||
showAuth(true);
|
||
return;
|
||
}
|
||
const amountInput = $("#web-topup-amount");
|
||
const resultEl = $("#web-topup-result");
|
||
const amount = Number(amountInput?.value || 0);
|
||
const minA = state.topup?.min_amount || 10;
|
||
const maxA = state.topup?.max_amount || 3000;
|
||
if (!Number.isFinite(amount) || amount < minA || amount > maxA) {
|
||
toast(`Сумма от ${minA} до ${maxA} ₽`);
|
||
return;
|
||
}
|
||
const old = btn.innerHTML;
|
||
btn.disabled = true;
|
||
btn.textContent = "Создаю счёт…";
|
||
if (resultEl) {
|
||
resultEl.classList.add("hidden");
|
||
resultEl.textContent = "";
|
||
}
|
||
try {
|
||
const data = await api("/api/topup/invoice", {
|
||
method: "POST",
|
||
body: JSON.stringify({ provider, amount }),
|
||
});
|
||
if (resultEl) {
|
||
resultEl.classList.remove("hidden");
|
||
resultEl.innerHTML = `${escapeHtml(data.message || "Счёт создан")}${
|
||
data.pay_url
|
||
? ` · <a class="link-accent" href="${escapeAttr(data.pay_url)}" target="_blank" rel="noopener">открыть оплату</a>`
|
||
: ""
|
||
}`;
|
||
}
|
||
toast(data.message || "Счёт создан");
|
||
if (data.pay_url) {
|
||
window.open(data.pay_url, "_blank", "noopener");
|
||
}
|
||
} catch (e) {
|
||
toast(e.message || "Ошибка");
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.innerHTML = old;
|
||
}
|
||
}
|
||
|
||
function renderWgConfigs(data) {
|
||
const card = $("#wg-configs-card");
|
||
const list = $("#wg-configs-list");
|
||
const buyBox = $("#wg-buy-box");
|
||
const hint = $("#wg-configs-hint");
|
||
const wgBtn = $("#btn-wg-go");
|
||
if (!card || !list) return;
|
||
|
||
if (!data || !data.enabled) {
|
||
card.classList.add("hidden");
|
||
if (wgBtn) wgBtn.classList.add("hidden");
|
||
return;
|
||
}
|
||
|
||
card.classList.remove("hidden");
|
||
if (wgBtn) wgBtn.classList.remove("hidden");
|
||
|
||
const used = data.used || (data.configs || []).length;
|
||
const max = data.max_configs || 10;
|
||
const priceLabel = data.price_label || (data.price > 0 ? `${data.price} ₽` : "бесплатно");
|
||
const subActive = data.subscription_active !== false;
|
||
hint.textContent = subActive
|
||
? `${used} / ${max} · ${priceLabel} · нужна активная подписка`
|
||
: `${used} / ${max} · подписка истекла — конфиги отключены`;
|
||
|
||
const servers = data.servers || [];
|
||
const sel = $("#wg-server-select");
|
||
if (sel) {
|
||
sel.innerHTML = servers.length
|
||
? servers
|
||
.map(
|
||
(s) =>
|
||
`<option value="${s.id}">${escapeHtml(s.name)}${
|
||
s.inbound_remark ? " · " + escapeHtml(s.inbound_remark) : ""
|
||
}</option>`
|
||
)
|
||
.join("")
|
||
: '<option value="">Нет доступных серверов</option>';
|
||
}
|
||
if (buyBox) {
|
||
buyBox.classList.toggle("hidden", used >= max || !servers.length || !subActive);
|
||
}
|
||
|
||
const configs = data.configs || [];
|
||
let banner = "";
|
||
if (!subActive) {
|
||
banner = `<p class="alert-inline">${escapeHtml(
|
||
data.subscription_reason ||
|
||
"Подписка истекла. Продлите тариф — затем снова можно включать конфиги."
|
||
)}</p>`;
|
||
}
|
||
if (!configs.length) {
|
||
list.innerHTML =
|
||
banner +
|
||
'<p class="muted small">Конфигов пока нет — выберите сервер выше и нажмите «Создать конфиг»</p>';
|
||
return;
|
||
}
|
||
list.innerHTML =
|
||
banner +
|
||
configs
|
||
.map((c) => {
|
||
const status = c.enabled ? "вкл" : "выкл";
|
||
const url = c.subscription_url || "";
|
||
const exp = c.expire_at
|
||
? new Date(c.expire_at).toLocaleString("ru-RU", {
|
||
day: "2-digit",
|
||
month: "short",
|
||
year: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
})
|
||
: "—";
|
||
const traffic = c.traffic_label || "1000 ГБ";
|
||
const enableBtn = c.enabled
|
||
? `<button type="button" class="btn ghost small-btn" data-wg="disable"><i class="fa-solid fa-pause"></i></button>`
|
||
: subActive
|
||
? `<button type="button" class="btn ghost small-btn" data-wg="enable"><i class="fa-solid fa-play"></i></button>`
|
||
: `<button type="button" class="btn ghost small-btn" disabled title="Нужна активная подписка"><i class="fa-solid fa-play"></i></button>`;
|
||
return `
|
||
<div class="wg-config-row" data-id="${c.id}" data-url="${escapeAttr(url)}">
|
||
<button type="button" class="wg-qr-thumb" data-wg="qr" title="Открыть QR" aria-label="QR код">
|
||
<img class="wg-qr-img" alt="QR" data-qr-id="${c.id}" />
|
||
</button>
|
||
<div class="wg-config-main">
|
||
<div><strong>${escapeHtml(c.remark || c.server_name || "WG")}</strong>
|
||
<span class="muted small">· ${escapeHtml(c.server_name || "")} · ${status}</span>
|
||
</div>
|
||
<p class="muted small wg-meta">До ${escapeHtml(exp)} · ${escapeHtml(traffic)}</p>
|
||
<code class="wg-sub-url">${escapeHtml(url)}</code>
|
||
<div class="wg-config-actions">
|
||
<button type="button" class="btn ghost small-btn" data-wg="qr"><i class="fa-solid fa-qrcode"></i> QR</button>
|
||
<button type="button" class="btn ghost small-btn" data-wg="copy"><i class="fa-solid fa-copy"></i></button>
|
||
${enableBtn}
|
||
<button type="button" class="btn ghost small-btn" data-wg="delete"><i class="fa-solid fa-trash"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
loadWgQrImages(list);
|
||
}
|
||
|
||
function escapeAttr(s) {
|
||
return String(s ?? "")
|
||
.replace(/&/g, "&")
|
||
.replace(/"/g, """)
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">");
|
||
}
|
||
|
||
async function fetchWgQrBlobUrl(configId) {
|
||
const headers = {};
|
||
const token = getStoredToken();
|
||
if (token) headers.Authorization = `Bearer ${token}`;
|
||
const res = await fetch(`/api/wg/configs/${configId}/qr`, {
|
||
headers,
|
||
credentials: "same-origin",
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
throw new Error(data.error || `QR HTTP ${res.status}`);
|
||
}
|
||
const blob = await res.blob();
|
||
return URL.createObjectURL(blob);
|
||
}
|
||
|
||
async function loadWgQrImages(root) {
|
||
const imgs = (root || document).querySelectorAll("img.wg-qr-img[data-qr-id]");
|
||
for (const img of imgs) {
|
||
const id = img.getAttribute("data-qr-id");
|
||
if (!id) continue;
|
||
try {
|
||
const objectUrl = await fetchWgQrBlobUrl(id);
|
||
if (img.dataset.objectUrl) URL.revokeObjectURL(img.dataset.objectUrl);
|
||
img.dataset.objectUrl = objectUrl;
|
||
img.src = objectUrl;
|
||
} catch {
|
||
img.replaceWith(
|
||
Object.assign(document.createElement("span"), {
|
||
className: "muted small",
|
||
textContent: "QR?",
|
||
})
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function openWgQrDialog(configId, url, title) {
|
||
const dialog = $("#wg-qr-dialog");
|
||
const img = $("#wg-qr-dialog-img");
|
||
const titleEl = $("#wg-qr-dialog-title");
|
||
const urlEl = $("#wg-qr-dialog-url");
|
||
if (!dialog || !img || !configId) {
|
||
toast("QR недоступен");
|
||
return;
|
||
}
|
||
titleEl.textContent = title || "QR WireGuard";
|
||
urlEl.textContent = url || "";
|
||
dialog.dataset.url = url || "";
|
||
img.removeAttribute("src");
|
||
img.alt = "Загрузка QR…";
|
||
try {
|
||
const objectUrl = await fetchWgQrBlobUrl(configId);
|
||
if (dialog.dataset.objectUrl) URL.revokeObjectURL(dialog.dataset.objectUrl);
|
||
dialog.dataset.objectUrl = objectUrl;
|
||
img.src = objectUrl;
|
||
img.alt = "QR код";
|
||
dialog.showModal();
|
||
} catch (ex) {
|
||
toast(ex.message || "Не удалось построить QR");
|
||
}
|
||
}
|
||
|
||
async function openAwgQrDialog(configId, url, title) {
|
||
const dialog = $("#wg-qr-dialog");
|
||
const img = $("#wg-qr-dialog-img");
|
||
const titleEl = $("#wg-qr-dialog-title");
|
||
const urlEl = $("#wg-qr-dialog-url");
|
||
if (!dialog || !img || !configId) {
|
||
toast("QR недоступен");
|
||
return;
|
||
}
|
||
titleEl.textContent = title || "QR AmneziaWG";
|
||
urlEl.textContent = url || "";
|
||
dialog.dataset.url = url || "";
|
||
img.removeAttribute("src");
|
||
img.alt = "Загрузка QR…";
|
||
try {
|
||
const objectUrl = await fetchAwgQrBlobUrl(configId);
|
||
if (dialog.dataset.objectUrl) URL.revokeObjectURL(dialog.dataset.objectUrl);
|
||
dialog.dataset.objectUrl = objectUrl;
|
||
img.src = objectUrl;
|
||
img.alt = "QR код";
|
||
dialog.showModal();
|
||
} catch (ex) {
|
||
toast(ex.message || "Не удалось построить QR");
|
||
}
|
||
}
|
||
|
||
async function loadWgConfigs() {
|
||
const card = $("#wg-configs-card");
|
||
if (!card) return;
|
||
try {
|
||
const data = await api("/api/wg/configs");
|
||
renderWgConfigs(data);
|
||
} catch {
|
||
card.classList.add("hidden");
|
||
$("#btn-wg-go")?.classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
function renderAwgConfigs(data) {
|
||
const card = $("#awg-configs-card");
|
||
const list = $("#awg-configs-list");
|
||
const buyBox = $("#awg-buy-box");
|
||
const hint = $("#awg-configs-hint");
|
||
const awgBtn = $("#btn-awg-go");
|
||
if (!card || !list) return;
|
||
|
||
if (!data || !data.enabled) {
|
||
card.classList.add("hidden");
|
||
if (awgBtn) awgBtn.classList.add("hidden");
|
||
return;
|
||
}
|
||
|
||
card.classList.remove("hidden");
|
||
if (awgBtn) awgBtn.classList.remove("hidden");
|
||
|
||
const used = data.used || (data.configs || []).length;
|
||
const max = data.max_configs || 10;
|
||
const priceLabel = data.price_label || (data.price > 0 ? `${data.price} ₽` : "бесплатно");
|
||
const subActive = data.subscription_active !== false;
|
||
hint.textContent = subActive
|
||
? `${used} / ${max} · ${priceLabel} · AmneziaVPN · активная подписка`
|
||
: `${used} / ${max} · подписка истекла — конфиги отключены`;
|
||
|
||
const servers = data.servers || [];
|
||
const sel = $("#awg-server-select");
|
||
if (sel) {
|
||
sel.innerHTML = servers.length
|
||
? servers
|
||
.map(
|
||
(s) =>
|
||
`<option value="${s.id}">${escapeHtml(s.name)} · ${escapeHtml(s.protocol || "awg2")}</option>`
|
||
)
|
||
.join("")
|
||
: '<option value="">Нет доступных серверов</option>';
|
||
}
|
||
if (buyBox) {
|
||
buyBox.classList.toggle("hidden", used >= max || !servers.length || !subActive);
|
||
}
|
||
|
||
const configs = data.configs || [];
|
||
let banner = "";
|
||
if (!subActive) {
|
||
banner = `<p class="alert-inline">${escapeHtml(
|
||
data.subscription_reason ||
|
||
"Подписка истекла. Продлите тариф — затем снова можно включать конфиги."
|
||
)}</p>`;
|
||
}
|
||
if (!configs.length) {
|
||
list.innerHTML =
|
||
banner +
|
||
'<p class="muted small">Конфигов пока нет — выберите сервер и создайте AWG-конфиг</p>';
|
||
return;
|
||
}
|
||
list.innerHTML =
|
||
banner +
|
||
configs
|
||
.map((c) => {
|
||
const status = c.enabled ? "вкл" : "выкл";
|
||
const link = c.vpn_link || c.config || "";
|
||
const exp = c.expire_at
|
||
? new Date(c.expire_at).toLocaleString("ru-RU", {
|
||
day: "2-digit",
|
||
month: "short",
|
||
year: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
})
|
||
: "—";
|
||
const enableBtn = c.enabled
|
||
? `<button type="button" class="btn ghost small-btn" data-awg="disable"><i class="fa-solid fa-pause"></i></button>`
|
||
: subActive
|
||
? `<button type="button" class="btn ghost small-btn" data-awg="enable"><i class="fa-solid fa-play"></i></button>`
|
||
: `<button type="button" class="btn ghost small-btn" disabled title="Нужна активная подписка"><i class="fa-solid fa-play"></i></button>`;
|
||
return `
|
||
<div class="wg-config-row" data-id="${c.id}" data-url="${escapeAttr(link)}">
|
||
<button type="button" class="wg-qr-thumb" data-awg="qr" title="Открыть QR" aria-label="QR код">
|
||
<img class="wg-qr-img" alt="QR" data-awg-qr-id="${c.id}" />
|
||
</button>
|
||
<div class="wg-config-main">
|
||
<div><strong>${escapeHtml(c.remark || c.client_name || c.server_name || "AWG")}</strong>
|
||
<span class="muted small">· ${escapeHtml(c.server_name || "")} · ${status}</span>
|
||
</div>
|
||
<p class="muted small wg-meta">До ${escapeHtml(exp)} · AmneziaWG 2.0</p>
|
||
<code class="wg-sub-url">${escapeHtml(link.slice(0, 64))}${link.length > 64 ? "…" : ""}</code>
|
||
<div class="wg-config-actions">
|
||
<button type="button" class="btn ghost small-btn" data-awg="qr"><i class="fa-solid fa-qrcode"></i> QR</button>
|
||
<button type="button" class="btn ghost small-btn" data-awg="copy"><i class="fa-solid fa-copy"></i></button>
|
||
${enableBtn}
|
||
<button type="button" class="btn ghost small-btn" data-awg="delete"><i class="fa-solid fa-trash"></i></button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
loadAwgQrImages(list);
|
||
}
|
||
|
||
async function fetchAwgQrBlobUrl(configId) {
|
||
const headers = {};
|
||
const token = getStoredToken();
|
||
if (token) headers.Authorization = `Bearer ${token}`;
|
||
if (initData) headers["X-Telegram-Init-Data"] = initData;
|
||
const res = await fetch(`/api/awg/configs/${configId}/qr`, {
|
||
credentials: "same-origin",
|
||
headers,
|
||
});
|
||
if (!res.ok) throw new Error("QR недоступен");
|
||
const blob = await res.blob();
|
||
return URL.createObjectURL(blob);
|
||
}
|
||
|
||
async function loadAwgQrImages(root) {
|
||
const imgs = (root || document).querySelectorAll("img.wg-qr-img[data-awg-qr-id]");
|
||
for (const img of imgs) {
|
||
const id = img.getAttribute("data-awg-qr-id");
|
||
if (!id) continue;
|
||
try {
|
||
const url = await fetchAwgQrBlobUrl(id);
|
||
img.src = url;
|
||
} catch {
|
||
img.alt = "QR н/д";
|
||
}
|
||
}
|
||
}
|
||
|
||
async function loadAwgConfigs() {
|
||
const card = $("#awg-configs-card");
|
||
if (!card) return;
|
||
try {
|
||
const data = await api("/api/awg/configs");
|
||
renderAwgConfigs(data);
|
||
} catch {
|
||
card.classList.add("hidden");
|
||
$("#btn-awg-go")?.classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
async function loadPublic() {
|
||
const t = await api("/api/tariffs");
|
||
state.tariffs = t.tariffs || [];
|
||
const c = await api("/api/clients");
|
||
renderClients(c.clients || []);
|
||
}
|
||
|
||
function loadTelegramLoginSdk() {
|
||
if (window.Telegram?.Login?.auth) {
|
||
return Promise.resolve();
|
||
}
|
||
if (loadTelegramLoginSdk._p) return loadTelegramLoginSdk._p;
|
||
loadTelegramLoginSdk._p = new Promise((resolve, reject) => {
|
||
const existing = document.querySelector('script[data-telegram-login-sdk]');
|
||
if (existing) {
|
||
existing.addEventListener("load", () => resolve());
|
||
existing.addEventListener("error", () => reject(new Error("Telegram Login SDK")));
|
||
return;
|
||
}
|
||
const script = document.createElement("script");
|
||
script.src = "https://telegram.org/js/telegram-login.js";
|
||
script.async = true;
|
||
script.dataset.telegramLoginSdk = "1";
|
||
script.onload = () => resolve();
|
||
script.onerror = () => reject(new Error("Не удалось загрузить Telegram Login SDK"));
|
||
document.head.appendChild(script);
|
||
});
|
||
return loadTelegramLoginSdk._p;
|
||
}
|
||
|
||
function mountTelegramWidget() {
|
||
const slot = $("#telegram-login-slot");
|
||
if (!slot) return;
|
||
slot.innerHTML = "";
|
||
const clientId = String(state.telegramClientId || "").trim();
|
||
if (!clientId) {
|
||
slot.innerHTML =
|
||
'<p class="muted small">Telegram Login недоступен (нет Client ID бота)</p>';
|
||
return;
|
||
}
|
||
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "btn telegram-oidc-btn full";
|
||
btn.innerHTML = '<i class="fa-brands fa-telegram"></i> Войти через Telegram';
|
||
btn.addEventListener("click", async () => {
|
||
btn.disabled = true;
|
||
try {
|
||
await loadTelegramLoginSdk();
|
||
const login = window.Telegram?.Login;
|
||
if (!login?.auth) {
|
||
throw new Error("Telegram Login SDK не готов");
|
||
}
|
||
login.auth(
|
||
{
|
||
client_id: Number(clientId),
|
||
scope: ["profile", "write"],
|
||
lang: "ru",
|
||
},
|
||
async (data) => {
|
||
try {
|
||
if (!data || data.error) {
|
||
throw new Error(data?.error || "Вход отменён");
|
||
}
|
||
const idToken = data.id_token;
|
||
if (!idToken) {
|
||
throw new Error("Telegram не вернул id_token");
|
||
}
|
||
await api("/api/auth/telegram", {
|
||
method: "POST",
|
||
body: JSON.stringify({ id_token: idToken }),
|
||
});
|
||
toast("Вход через Telegram выполнен");
|
||
await loadMe();
|
||
renderTariffs();
|
||
} catch (e) {
|
||
toast(e.message || "Ошибка Telegram-входа");
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
);
|
||
} catch (e) {
|
||
toast(e.message || "Ошибка Telegram-входа");
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
slot.appendChild(btn);
|
||
}
|
||
|
||
$("#form-login").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
try {
|
||
await api("/api/auth/login", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
login: fd.get("login"),
|
||
password: fd.get("password"),
|
||
}),
|
||
});
|
||
toast("Вы вошли");
|
||
await loadMe();
|
||
renderTariffs();
|
||
} catch (err) {
|
||
toast(err.message || "Ошибка входа");
|
||
}
|
||
});
|
||
|
||
$("#form-register").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
if (fd.get("accept_terms") !== "on") {
|
||
toast("Нужно принять оферту и правила сервиса");
|
||
return;
|
||
}
|
||
try {
|
||
await api("/api/auth/register", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
login: fd.get("login"),
|
||
password: fd.get("password"),
|
||
full_name: fd.get("full_name") || "",
|
||
auto_renew: fd.get("auto_renew") === "on",
|
||
accept_terms: true,
|
||
}),
|
||
});
|
||
toast("Аккаунт создан");
|
||
await loadMe();
|
||
renderTariffs();
|
||
} catch (err) {
|
||
toast(err.message || "Ошибка регистрации");
|
||
}
|
||
});
|
||
|
||
async function startQrAuth() {
|
||
stopQrPoll();
|
||
try {
|
||
const data = await api("/api/auth/qr/start", {
|
||
method: "POST",
|
||
body: "{}",
|
||
});
|
||
const box = $("#qr-auth-box");
|
||
const img = $("#qr-auth-img");
|
||
const link = $("#qr-auth-link");
|
||
const status = $("#qr-auth-status");
|
||
box?.classList.remove("hidden");
|
||
if (img) img.src = data.qr_svg || "";
|
||
if (link) {
|
||
link.href = data.deep_link || data.web_approve_url || "#";
|
||
link.classList.toggle("hidden", !(data.deep_link || data.web_approve_url));
|
||
}
|
||
if (status) status.textContent = "Ожидание подтверждения в Telegram…";
|
||
const token = data.token;
|
||
qrPollTimer = setInterval(async () => {
|
||
try {
|
||
const poll = await api(
|
||
`/api/auth/qr/poll?token=${encodeURIComponent(token)}`
|
||
);
|
||
if (poll.status === "approved") {
|
||
stopQrPoll();
|
||
if (poll.token) setStoredToken(poll.token);
|
||
toast("Вход по QR выполнен");
|
||
await loadMe();
|
||
renderTariffs();
|
||
} else if (poll.status === "expired" || poll.status === "consumed") {
|
||
stopQrPoll();
|
||
if (status) status.textContent = "QR истёк — нажмите кнопку снова";
|
||
}
|
||
} catch {
|
||
/* keep polling */
|
||
}
|
||
}, 2000);
|
||
} catch (err) {
|
||
toast(err.message || "Не удалось создать QR");
|
||
}
|
||
}
|
||
|
||
$("#btn-auth-qr")?.addEventListener("click", startQrAuth);
|
||
$("#btn-qr-cancel")?.addEventListener("click", stopQrPoll);
|
||
|
||
async function loginWithPasskey() {
|
||
if (!window.PublicKeyCredential) {
|
||
toast("Passkey не поддерживается в этом браузере");
|
||
return;
|
||
}
|
||
try {
|
||
const login = ($("#form-login input[name=login]")?.value || "").trim();
|
||
const opt = await api("/api/auth/passkey/login/options", {
|
||
method: "POST",
|
||
body: JSON.stringify(login ? { login } : {}),
|
||
});
|
||
const cred = await navigator.credentials.get({
|
||
publicKey: preparePublicKeyGet(opt.options),
|
||
});
|
||
if (!cred) {
|
||
toast("Вход отменён");
|
||
return;
|
||
}
|
||
await api("/api/auth/passkey/login/verify", {
|
||
method: "POST",
|
||
body: JSON.stringify({ credential: credentialToJSON(cred) }),
|
||
});
|
||
toast("Вход с Passkey выполнен");
|
||
await loadMe();
|
||
renderTariffs();
|
||
} catch (err) {
|
||
toast(err.message || "Ошибка Passkey");
|
||
}
|
||
}
|
||
|
||
$("#btn-auth-passkey")?.addEventListener("click", loginWithPasskey);
|
||
|
||
$("#btn-passkey-add")?.addEventListener("click", async () => {
|
||
if (!window.PublicKeyCredential) {
|
||
toast("Passkey не поддерживается");
|
||
return;
|
||
}
|
||
try {
|
||
const opt = await api("/api/auth/passkey/register/options", {
|
||
method: "POST",
|
||
body: "{}",
|
||
});
|
||
const cred = await navigator.credentials.create({
|
||
publicKey: preparePublicKeyCreate(opt.options),
|
||
});
|
||
if (!cred) {
|
||
toast("Отменено");
|
||
return;
|
||
}
|
||
await api("/api/auth/passkey/register/verify", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
credential: credentialToJSON(cred),
|
||
device_name: navigator.platform || "Устройство",
|
||
}),
|
||
});
|
||
toast("Passkey добавлен");
|
||
await loadPasskeys();
|
||
} catch (err) {
|
||
toast(err.message || "Не удалось добавить Passkey");
|
||
}
|
||
});
|
||
|
||
$("#form-email")?.addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
try {
|
||
const data = await api("/api/account/email", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
email: ($("#account-email")?.value || "").trim(),
|
||
}),
|
||
});
|
||
if (state.me?.user && data.user) {
|
||
state.me.user.email = data.user.email;
|
||
}
|
||
syncEmailUi();
|
||
toast("Email сохранён");
|
||
} catch (err) {
|
||
toast(err.message || "Не удалось сохранить email");
|
||
}
|
||
});
|
||
|
||
$("#btn-ref-copy")?.addEventListener("click", async () => {
|
||
const text = $("#referral-link")?.dataset?.copy || $("#referral-link")?.textContent || "";
|
||
const ok = await copyText(text);
|
||
toast(ok ? "Ссылка скопирована" : "Не удалось скопировать");
|
||
});
|
||
|
||
$("#btn-trial")?.addEventListener("click", async () => {
|
||
try {
|
||
const data = await api("/api/trial", { method: "POST", body: "{}" });
|
||
toast(data.message || "Пробный период активирован");
|
||
await loadMe();
|
||
} catch (err) {
|
||
toast(err.message || "Недоступно");
|
||
}
|
||
});
|
||
|
||
$("#btn-hwid-refresh")?.addEventListener("click", () => loadHwid());
|
||
|
||
$("#btn-logout-all")?.addEventListener("click", async () => {
|
||
try {
|
||
const data = await api("/api/account/sessions/logout-all", {
|
||
method: "POST",
|
||
body: "{}",
|
||
});
|
||
toast(`Закрыто сессий: ${data.deleted || 0}`);
|
||
} catch (err) {
|
||
toast(err.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
async function saveNotify() {
|
||
try {
|
||
await api("/api/account/notifications", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
notify_telegram: !!$("#notify-tg-toggle")?.checked,
|
||
notify_email: !!$("#notify-email-toggle")?.checked,
|
||
}),
|
||
});
|
||
toast("Настройки уведомлений сохранены");
|
||
} catch (err) {
|
||
toast(err.message || "Ошибка");
|
||
}
|
||
}
|
||
$("#notify-tg-toggle")?.addEventListener("change", saveNotify);
|
||
$("#notify-email-toggle")?.addEventListener("change", saveNotify);
|
||
|
||
$("#btn-forgot-password")?.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
showAuth(true);
|
||
setAuthMode("forgot");
|
||
});
|
||
$("#btn-forgot-back")?.addEventListener("click", () => {
|
||
setAuthMode("login");
|
||
});
|
||
$("#form-forgot")?.addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
const status = $("#forgot-status");
|
||
const btn = $("#btn-forgot-submit");
|
||
if (status) status.textContent = "Отправка…";
|
||
if (btn) btn.disabled = true;
|
||
try {
|
||
const data = await api("/api/auth/password/forgot", {
|
||
method: "POST",
|
||
body: JSON.stringify({ email: fd.get("email") }),
|
||
});
|
||
const msg =
|
||
data.message ||
|
||
"Если email найден — отправили ссылку. Проверьте почту и «Спам».";
|
||
if (status) status.textContent = msg;
|
||
toast(msg);
|
||
} catch (err) {
|
||
const msg = err.message || "Ошибка";
|
||
if (status) status.textContent = msg;
|
||
toast(msg);
|
||
} finally {
|
||
if (btn) btn.disabled = false;
|
||
}
|
||
});
|
||
$("#form-reset")?.addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const fd = new FormData(e.target);
|
||
const params = new URLSearchParams(location.search);
|
||
const status = $("#reset-status");
|
||
if (status) status.textContent = "Сохранение…";
|
||
try {
|
||
await api("/api/auth/password/reset", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
token: params.get("reset") || state._resetToken,
|
||
password: fd.get("password"),
|
||
}),
|
||
});
|
||
toast("Пароль обновлён");
|
||
if (status) status.textContent = "Готово — вы вошли";
|
||
state._resetToken = "";
|
||
state._forceResetUi = false;
|
||
history.replaceState({}, "", location.pathname);
|
||
await loadMe();
|
||
renderTariffs();
|
||
} catch (err) {
|
||
const msg = err.message || "Ошибка сброса";
|
||
if (status) status.textContent = msg;
|
||
toast(msg);
|
||
}
|
||
});
|
||
|
||
// ref + reset from URL
|
||
(async () => {
|
||
const params = new URLSearchParams(location.search);
|
||
const ref = params.get("ref");
|
||
if (ref) {
|
||
try {
|
||
document.cookie = `vpn_ref=${encodeURIComponent(ref)};path=/;max-age=604800;samesite=lax`;
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
const reset = params.get("reset");
|
||
if (reset) {
|
||
state._resetToken = reset;
|
||
state._forceResetUi = true;
|
||
showAuth(true);
|
||
setAuthMode("reset");
|
||
}
|
||
})();
|
||
|
||
// Подтверждение QR с другого уже вошедшего устройства
|
||
(async () => {
|
||
const params = new URLSearchParams(location.search);
|
||
const approveToken = params.get("qr_approve");
|
||
if (!approveToken) return;
|
||
try {
|
||
await api("/api/auth/qr/approve", {
|
||
method: "POST",
|
||
body: JSON.stringify({ token: approveToken }),
|
||
});
|
||
toast("Вход на другом устройстве подтверждён");
|
||
history.replaceState({}, "", location.pathname);
|
||
} catch (err) {
|
||
state._pendingQrApprove = approveToken;
|
||
toast(err.message || "Войдите, чтобы подтвердить QR");
|
||
}
|
||
})();
|
||
$("#btn-logout").addEventListener("click", async () => {
|
||
stopQrPoll();
|
||
try {
|
||
await api("/api/auth/logout", { method: "POST", body: "{}" });
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
setStoredToken("");
|
||
state.me = null;
|
||
state.authed = false;
|
||
showAuth(true);
|
||
renderTariffs();
|
||
renderWebTopup();
|
||
toast("Вы вышли");
|
||
});
|
||
|
||
$("#refresh-servers").addEventListener("click", loadServers);
|
||
$("#copy-sub").addEventListener("click", async () => {
|
||
const url = $("#sub-url").textContent;
|
||
const ok = await copyText(url);
|
||
toast(ok ? "Ссылка скопирована" : "Не удалось скопировать");
|
||
});
|
||
$("#btn-renew-go").addEventListener("click", () => switchTab("tariffs"));
|
||
$("#btn-onboarding-tariffs")?.addEventListener("click", () => switchTab("tariffs"));
|
||
$("#balance-pill")?.addEventListener("click", () => {
|
||
if (state.authed) switchTab("balance");
|
||
});
|
||
|
||
$("#auto-renew-toggle")?.addEventListener("change", async (e) => {
|
||
const enabled = !!e.target.checked;
|
||
const prev = !!state.me?.user?.auto_renew;
|
||
if (state.me?.user) state.me.user.auto_renew = enabled;
|
||
syncAutoRenewUi();
|
||
try {
|
||
const data = await api("/api/auto-renew", {
|
||
method: "POST",
|
||
body: JSON.stringify({ enabled }),
|
||
});
|
||
if (state.me?.user) {
|
||
state.me.user.auto_renew = !!data.auto_renew;
|
||
}
|
||
syncAutoRenewUi();
|
||
toast(enabled ? "Автопродление включено" : "Автопродление выключено");
|
||
} catch (err) {
|
||
if (state.me?.user) {
|
||
state.me.user.auto_renew = prev;
|
||
}
|
||
syncAutoRenewUi();
|
||
toast(err.message || "Не удалось сохранить");
|
||
}
|
||
});
|
||
$("#btn-traffic-go").addEventListener("click", () => {
|
||
loadTrafficPacks();
|
||
switchTab("extras");
|
||
});
|
||
$("#btn-devices-go").addEventListener("click", () => {
|
||
loadDevicePacks();
|
||
switchTab("extras");
|
||
});
|
||
$("#btn-wg-go")?.addEventListener("click", () => {
|
||
loadWgConfigs();
|
||
switchTab("configs");
|
||
});
|
||
$("#btn-awg-go")?.addEventListener("click", () => {
|
||
loadAwgConfigs();
|
||
switchTab("configs");
|
||
});
|
||
$("#btn-wg-buy")?.addEventListener("click", async () => {
|
||
const serverId = Number($("#wg-server-select")?.value || 0);
|
||
if (!serverId) {
|
||
toast("Выберите сервер");
|
||
return;
|
||
}
|
||
try {
|
||
const data = await api("/api/wg/configs", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
server_id: serverId,
|
||
remark: ($("#wg-remark")?.value || "").trim(),
|
||
}),
|
||
});
|
||
if (data.balance != null && state.me) {
|
||
state.me.balance = data.balance;
|
||
setBalancePill(data.balance);
|
||
}
|
||
await loadWgConfigs();
|
||
toast("Конфиг создан");
|
||
if ($("#wg-remark")) $("#wg-remark").value = "";
|
||
} catch (ex) {
|
||
toast((ex.message || "Ошибка").replace(/<[^>]+>/g, ""));
|
||
}
|
||
});
|
||
$("#btn-awg-buy")?.addEventListener("click", async () => {
|
||
const serverId = Number($("#awg-server-select")?.value || 0);
|
||
if (!serverId) {
|
||
toast("Выберите сервер");
|
||
return;
|
||
}
|
||
try {
|
||
const data = await api("/api/awg/configs", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
server_id: serverId,
|
||
remark: ($("#awg-remark")?.value || "").trim(),
|
||
}),
|
||
});
|
||
if (data.balance != null && state.me) {
|
||
state.me.balance = data.balance;
|
||
setBalancePill(data.balance);
|
||
}
|
||
await loadAwgConfigs();
|
||
toast("AWG-конфиг создан");
|
||
if ($("#awg-remark")) $("#awg-remark").value = "";
|
||
} catch (ex) {
|
||
toast((ex.message || "Ошибка").replace(/<[^>]+>/g, ""));
|
||
}
|
||
});
|
||
$("#wg-configs-list")?.addEventListener("click", async (e) => {
|
||
const btn = e.target.closest("[data-wg]");
|
||
if (!btn) return;
|
||
const row = btn.closest(".wg-config-row");
|
||
if (!row) return;
|
||
const id = row.dataset.id;
|
||
const act = btn.getAttribute("data-wg");
|
||
try {
|
||
if (act === "qr") {
|
||
const url = row.dataset.url || row.querySelector(".wg-sub-url")?.textContent || "";
|
||
const title = row.querySelector("strong")?.textContent || "WireGuard";
|
||
await openWgQrDialog(id, url, title);
|
||
return;
|
||
}
|
||
if (act === "copy") {
|
||
const url = row.dataset.url || row.querySelector(".wg-sub-url")?.textContent || "";
|
||
const ok = await copyText(url);
|
||
toast(ok ? "Ссылка скопирована" : "Не удалось скопировать");
|
||
return;
|
||
}
|
||
if (act === "delete") {
|
||
if (!confirm("Удалить конфиг? Слот освободится.")) return;
|
||
await api(`/api/wg/configs/${id}`, { method: "DELETE" });
|
||
toast("Удалено");
|
||
await loadWgConfigs();
|
||
return;
|
||
}
|
||
if (act === "enable" || act === "disable") {
|
||
await api(`/api/wg/configs/${id}/${act}`, { method: "POST", body: "{}" });
|
||
toast(act === "enable" ? "Включено" : "Выключено");
|
||
await loadWgConfigs();
|
||
}
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
$("#awg-configs-list")?.addEventListener("click", async (e) => {
|
||
const btn = e.target.closest("[data-awg]");
|
||
if (!btn) return;
|
||
const row = btn.closest(".wg-config-row");
|
||
if (!row) return;
|
||
const id = row.dataset.id;
|
||
const act = btn.getAttribute("data-awg");
|
||
try {
|
||
if (act === "copy") {
|
||
const url = row.dataset.url || "";
|
||
const ok = await copyText(url);
|
||
toast(ok ? "Ссылка скопирована" : "Не удалось скопировать");
|
||
return;
|
||
}
|
||
if (act === "qr") {
|
||
const url = row.dataset.url || "";
|
||
const title = row.querySelector("strong")?.textContent || "AWG";
|
||
await openAwgQrDialog(id, url, title);
|
||
return;
|
||
}
|
||
if (act === "delete") {
|
||
if (!confirm("Удалить AWG-конфиг? Слот освободится.")) return;
|
||
await api(`/api/awg/configs/${id}`, { method: "DELETE" });
|
||
toast("Удалено");
|
||
await loadAwgConfigs();
|
||
return;
|
||
}
|
||
if (act === "enable" || act === "disable") {
|
||
await api(`/api/awg/configs/${id}/${act}`, { method: "POST", body: "{}" });
|
||
toast(act === "enable" ? "Включено" : "Выключено");
|
||
await loadAwgConfigs();
|
||
}
|
||
} catch (ex) {
|
||
toast(ex.message || "Ошибка");
|
||
}
|
||
});
|
||
|
||
$("#btn-wg-qr-copy")?.addEventListener("click", async () => {
|
||
const url = $("#wg-qr-dialog")?.dataset.url || $("#wg-qr-dialog-url")?.textContent || "";
|
||
if (!url) return;
|
||
const ok = await copyText(url);
|
||
toast(ok ? "Ссылка скопирована" : "Не удалось скопировать");
|
||
});
|
||
|
||
function setupTopupCode(payUrl) {
|
||
const buy = $("#topup-code-buy");
|
||
const hint = $("#topup-code-hint");
|
||
if (hint) {
|
||
hint.innerHTML = payUrl
|
||
? "Карта, СБП и др. — на странице покупки <b>40+ способов оплаты</b>. После оплаты введите код ниже."
|
||
: "Для карт и СБП купите код в боте («Баланс»). Напрямую без кода — только крипта.";
|
||
}
|
||
if (!buy) return;
|
||
if (payUrl) {
|
||
buy.href = payUrl;
|
||
buy.classList.remove("hidden");
|
||
} else {
|
||
buy.classList.add("hidden");
|
||
}
|
||
}
|
||
|
||
$("#tx-prev")?.addEventListener("click", () => {
|
||
if (txPage > 1) loadTransactions(txPage - 1);
|
||
});
|
||
$("#tx-next")?.addEventListener("click", () => {
|
||
loadTransactions(txPage + 1);
|
||
});
|
||
|
||
const topupForm = $("#form-topup-code");
|
||
if (topupForm) {
|
||
topupForm.addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
if (!state.authed) {
|
||
toast("Сначала войдите в аккаунт");
|
||
showAuth(true);
|
||
return;
|
||
}
|
||
const fd = new FormData(e.target);
|
||
try {
|
||
const data = await api("/api/topup/redeem", {
|
||
method: "POST",
|
||
body: JSON.stringify({ code: fd.get("code") }),
|
||
});
|
||
toast(data.message || "Баланс пополнен");
|
||
e.target.reset();
|
||
await loadMe();
|
||
txPage = 1;
|
||
await loadTransactions(1);
|
||
} catch (err) {
|
||
toast(err.message || "Не удалось активировать код");
|
||
}
|
||
});
|
||
}
|
||
|
||
async function boot() {
|
||
try {
|
||
const cfg = await api("/api/config");
|
||
state.botUsername = cfg.bot_username || "";
|
||
state.telegramClientId = cfg.telegram_client_id || "";
|
||
state.starsEnabled = !!cfg.stars_enabled;
|
||
state.starsRubPerStar = Number(cfg.stars_rub_per_star) || 1.5;
|
||
state.topup = cfg.topup || null;
|
||
setupTopupCode(cfg.topup_code_pay_url || "");
|
||
renderWebTopup();
|
||
} catch {
|
||
state.botUsername = "";
|
||
state.telegramClientId = "";
|
||
state.starsEnabled = false;
|
||
state.topup = null;
|
||
setupTopupCode("");
|
||
renderWebTopup();
|
||
}
|
||
|
||
try {
|
||
await loadPublic();
|
||
} catch (e) {
|
||
toast(e.message || "Не удалось загрузить каталог");
|
||
}
|
||
|
||
if (initData) {
|
||
try {
|
||
await loadMe();
|
||
renderTariffs();
|
||
} catch (e) {
|
||
$("#user-name").textContent = "Ошибка";
|
||
toast(e.message || "Не удалось войти через Telegram");
|
||
showAuth(true);
|
||
if (state._forceResetUi) setAuthMode("reset");
|
||
else setAuthMode("login");
|
||
mountTelegramWidget();
|
||
renderTariffs();
|
||
loadStarsPacks();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Браузер: пробуем сессию cookie/token
|
||
try {
|
||
await loadMe();
|
||
renderTariffs();
|
||
} catch {
|
||
showAuth(true);
|
||
if (state._forceResetUi) setAuthMode("reset");
|
||
else setAuthMode("login");
|
||
mountTelegramWidget();
|
||
renderTariffs();
|
||
loadStarsPacks();
|
||
}
|
||
}
|
||
|
||
boot();
|
||
})();
|