Files
vpn-telegram-bot/webapp/static/app.js
T

2524 lines
84 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
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}&nbsp;₽`;
}
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(/&nbsp;/g, " ")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
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();
})();