(() => { 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 = ` —`; return; } const n = typeof amount === "number" ? Math.round(amount) : amount; el.innerHTML = ` ${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 / при открытом 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 = `${e.message || "Не удалось загрузить пакеты"}`; } } 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 = 'Операций пока нет'; 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 = ` ${tx.label || "Операция"} ${date} ${tx.amount_label || ""} `; list.appendChild(li); }); syncTxPager(txPage, pages, total); card.classList.remove("hidden"); } catch { list.innerHTML = 'Не удалось загрузить историю'; 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 = `Подписка на паузе: слишком много устройств · до ${exp}`; } else { setSubBadge("ok", "Активна"); statusEl.classList.add("status-ok"); statusEl.innerHTML = `Работает до ${exp}`; } 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 = ` 📈 ${p.title} · ${p.price_label} `; 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 = ` ⚠ Оплатить +${excess} устр. · ${price} ₽ `; box.appendChild(el); } (data.packs || []).forEach((p) => { const el = document.createElement("div"); el.className = "actions"; el.innerHTML = ` ➕ ${p.title} · ${p.price_label} `; 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 = ` Тарифы скоро появятся Обновите страницу или загляните чуть позже `; return; } const promoWrap = document.createElement("div"); promoWrap.className = "auth-form"; promoWrap.innerHTML = ` Промокод `; 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 = ` ${t.title} ${t.description || ""} ${t.days} дн. ${traffic} ${t.device_limit} устр. ${t.price_label} ${hasSub ? "Купить" : "Начать"} ${ hasSub ? ` Продлить` : ` Продлить` } `; 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 = ` Клиенты пока не загрузились Загляните позже или напишите в тикеты — поможем подобрать приложение `; return; } clients.forEach((c) => { const el = document.createElement("article"); el.className = "client"; const badges = [ c.featured ? 'рекомендуем' : "", c.hwid ? 'HWID' : "", `${c.core}`, ] .filter(Boolean) .join(""); el.innerHTML = ` ${c.name} ${c.description} ${badges} ${c.links .map((l) => `${l.title}`) .join("")} `; box.appendChild(el); }); } function stripHtml(html) { return String(html || "") .replace(//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 = 'Загрузка…'; try { const data = await api("/api/faq"); box.innerHTML = ""; (data.items || []).forEach((item) => { const el = document.createElement("details"); el.className = "faq-item"; el.innerHTML = ` ${item.question} ${stripHtml(item.answer)} `; box.appendChild(el); }); } catch (e) { box.innerHTML = `${e.message || "Ошибка"}`; } } let ticketsMeta = { max_open: 3, open_count: 0 }; let activeTicketId = null; function escapeHtml(s) { return String(s) .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 = 'Войдите, чтобы создавать тикеты'; $("#ticket-create-form").classList.add("hidden"); $("#ticket-thread").classList.add("hidden"); return; } showTicketList(); const box = $("#tickets-list"); box.innerHTML = 'Загрузка…'; 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 = 'Тикетов пока нет'; return; } tickets.forEach((t) => { const el = document.createElement("button"); el.type = "button"; el.className = "ticket-item"; el.dataset.id = t.id; el.innerHTML = ` #${t.id} · ${escapeHtml(t.subject)} ${fmtTicketDate(t.updated_at)} ${escapeHtml(t.status_label || t.status)} `; box.appendChild(el); }); } catch (e) { box.innerHTML = `${e.message || "Ошибка"}`; } } 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 = ` ${who} · ${fmtTicketDate(m.created_at)} ${escapeHtml(m.body)} `; 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 = ` ${n.name} ${n.country} · ${n.label} ${ping} `; 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 = "Нет привязанных устройств"; 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 = `${title}`; 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 = "Не удалось загрузить"; } } 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 = "Passkey ещё не добавлен"; return; } list.innerHTML = ""; items.forEach((p) => { const li = document.createElement("li"); li.innerHTML = `${p.device_name || "Passkey"}`; 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 = "Не удалось загрузить"; } } 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 = 'Войдите, чтобы пополнить баланс картой, СБП или криптой.'; return; } providersBox.innerHTML = ""; (topup.providers || []).forEach((p) => { const btn = document.createElement("button"); btn.type = "button"; btn.className = "btn secondary full"; btn.innerHTML = `${escapeHtml(p.title)}${escapeHtml(p.hint || "")}`; 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 ? ` · открыть оплату` : "" }`; } 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) => `${escapeHtml(s.name)}${ s.inbound_remark ? " · " + escapeHtml(s.inbound_remark) : "" }` ) .join("") : 'Нет доступных серверов'; } if (buyBox) { buyBox.classList.toggle("hidden", used >= max || !servers.length || !subActive); } const configs = data.configs || []; let banner = ""; if (!subActive) { banner = `${escapeHtml( data.subscription_reason || "Подписка истекла. Продлите тариф — затем снова можно включать конфиги." )}`; } if (!configs.length) { list.innerHTML = banner + 'Конфигов пока нет — выберите сервер выше и нажмите «Создать конфиг»'; 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 ? `` : subActive ? `` : ``; return ` ${escapeHtml(c.remark || c.server_name || "WG")} · ${escapeHtml(c.server_name || "")} · ${status} До ${escapeHtml(exp)} · ${escapeHtml(traffic)} ${escapeHtml(url)} QR ${enableBtn} `; }) .join(""); loadWgQrImages(list); } function escapeAttr(s) { return String(s ?? "") .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) => `${escapeHtml(s.name)} · ${escapeHtml(s.protocol || "awg2")}` ) .join("") : 'Нет доступных серверов'; } if (buyBox) { buyBox.classList.toggle("hidden", used >= max || !servers.length || !subActive); } const configs = data.configs || []; let banner = ""; if (!subActive) { banner = `${escapeHtml( data.subscription_reason || "Подписка истекла. Продлите тариф — затем снова можно включать конфиги." )}`; } if (!configs.length) { list.innerHTML = banner + 'Конфигов пока нет — выберите сервер и создайте AWG-конфиг'; 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 ? `` : subActive ? `` : ``; return ` ${escapeHtml(c.remark || c.client_name || c.server_name || "AWG")} · ${escapeHtml(c.server_name || "")} · ${status} До ${escapeHtml(exp)} · AmneziaWG 2.0 ${escapeHtml(link.slice(0, 64))}${link.length > 64 ? "…" : ""} QR ${enableBtn} `; }) .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 = 'Telegram Login недоступен (нет Client ID бота)'; return; } const btn = document.createElement("button"); btn.type = "button"; btn.className = "btn telegram-oidc-btn full"; btn.innerHTML = ' Войти через 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 ? "Карта, СБП и др. — на странице покупки 40+ способов оплаты. После оплаты введите код ниже." : "Для карт и СБП купите код в боте («Баланс»). Напрямую без кода — только крипта."; } 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(); })();
${e.message || "Не удалось загрузить пакеты"}
Тарифы скоро появятся
Обновите страницу или загляните чуть позже
${t.description || ""}
${t.price_label}
Клиенты пока не загрузились
Загляните позже или напишите в тикеты — поможем подобрать приложение
${c.description}
Загрузка…
${e.message || "Ошибка"}
Войдите, чтобы создавать тикеты
Тикетов пока нет
Войдите, чтобы пополнить баланс картой, СБП или криптой.
${escapeHtml( data.subscription_reason || "Подписка истекла. Продлите тариф — затем снова можно включать конфиги." )}
Конфигов пока нет — выберите сервер выше и нажмите «Создать конфиг»
До ${escapeHtml(exp)} · ${escapeHtml(traffic)}
${escapeHtml(url)}
Конфигов пока нет — выберите сервер и создайте AWG-конфиг
До ${escapeHtml(exp)} · AmneziaWG 2.0
${escapeHtml(link.slice(0, 64))}${link.length > 64 ? "…" : ""}
Telegram Login недоступен (нет Client ID бота)