cosmo-pay/web/app.js

409 lines
20 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.

// Cosmo VPN PWA: trial, purchase and renewal without Telegram (same Cosmo Pay API as the Android app).
// The PWA can't be a VPN itself on iOS: it manages the subscription and hands it to a VPN client (Happ).
"use strict";
const API = "/cosmo-pay/api/v1";
const HAPP_IOS = "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973";
const HAPP_ANDROID = "https://play.google.com/store/apps/details?id=com.happproxy";
const SUB_RE = /^https:\/\/sub\.cosmonet\.online\/[A-Za-z0-9_-]{8,64}\/?$/;
const PENDING_TTL = 3 * 3600 * 1000;
const ua = navigator.userAgent;
const isIOS = /iPhone|iPad|iPod/.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
const isAndroid = /Android/.test(ua);
const standalone = window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
// ---------------------------------------------------------------- storage (safe in private mode)
const store = {
get(k) { try { return localStorage.getItem(k); } catch (_) { return null; } },
set(k, v) { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, v); } catch (_) {} },
};
// Device id for the server (same role as the Android HWID): random, 32 hex, kept in this browser/PWA.
function webId() {
let id = store.get("cosmo_web_id");
if (!/^[0-9a-f]{32}$/.test(id || "")) {
const b = new Uint8Array(16);
crypto.getRandomValues(b);
id = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
store.set("cosmo_web_id", id);
}
return id;
}
let subUrl = store.get("cosmo_sub_url");
const who = () => (subUrl ? { subscription_url: subUrl } : { hwid: webId() });
async function api(path, body) {
const r = await fetch(`${API}/${path}`, {
method: body ? "POST" : "GET",
headers: body ? { "Content-Type": "application/json" } : {},
body: body ? JSON.stringify(body) : undefined,
});
if (!r.ok) { const e = new Error(`HTTP ${r.status}`); e.status = r.status; throw e; }
return r.json();
}
// ---------------------------------------------------------------- helpers
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const fmtDate = (iso) => { const d = new Date(iso); return isNaN(d) ? "" : d.toLocaleDateString("ru-RU"); };
function plural(n, one, few, many) {
const m10 = n % 10, m100 = n % 100;
if (m10 === 1 && m100 !== 11) return one;
if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few;
return many;
}
function toast(text) {
const t = $("toast");
t.textContent = text; t.style.display = "block";
clearTimeout(toast.timer); toast.timer = setTimeout(() => (t.style.display = "none"), 3500);
}
function setSubUrl(url) {
subUrl = url; store.set("cosmo_sub_url", url);
refreshPushIdentity();
}
const spinner = '<div class="spinner"></div>';
const logo = '<img class="logo" src="icons/icon-192.png" alt="">';
function installBanner() {
if (standalone) return "";
if (isIOS) {
return `<div class="banner">📲 <b>Установите Cosmo VPN на экран «Домой»</b>: нажмите «Поделиться» <span aria-hidden="true">⬆️</span>
внизу Safari → «На экран „Домой“». Так приложение запомнит подписку и сможет присылать уведомления.</div>`;
}
if (deferredInstall) {
return `<div class="banner">📲 <b>Установите Cosmo VPN</b> на главный экран.
<button class="btn outline" id="installBtn">Установить</button></div>`;
}
return "";
}
let deferredInstall = null;
window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); deferredInstall = e; });
// ---------------------------------------------------------------- views
async function start() {
const params = new URLSearchParams(location.search);
if (params.has("paid") || params.has("renew")) history.replaceState(null, "", location.pathname);
if (!subUrl) {
try {
const st = await api("device", { hwid: webId() });
if (st.subscription_url) setSubUrl(st.subscription_url);
else return welcome(st);
} catch (e) {
return welcome(null);
}
}
await home();
if (params.has("renew")) openSheet();
}
function welcome(st) {
const days = st && st.trial_available ? st.trial_days : null;
$("view").innerHTML = `
${logo}
<h1 class="center">Добро пожаловать в Cosmo VPN</h1>
<p class="center muted">Попробуйте бесплатно или купите подписку прямо здесь — Telegram не нужен.</p>
${installBanner()}
${days ? `<button class="btn" id="trialBtn">Попробовать бесплатно — ${days} ${plural(days, "день", "дня", "дней")}</button>` : ""}
<button class="btn ${days ? "outline" : ""}" id="buyBtn">Купить подписку</button>
<div id="welcomeErr"></div>
<div class="card">
<p><b>Уже есть подписка?</b></p>
<p class="muted" style="font-size:14px;margin-top:4px">Вставьте ссылку подписки из бота (https://sub.cosmonet.online/…).</p>
<input class="link" id="linkInput" type="url" inputmode="url" autocomplete="off" placeholder="https://sub.cosmonet.online/…">
<button class="btn outline" id="linkBtn">Добавить подписку</button>
</div>`;
bindInstall();
$("buyBtn").onclick = () => openSheet();
$("linkBtn").onclick = addLink;
if (days) $("trialBtn").onclick = activateTrial;
}
async function activateTrial() {
const btn = $("trialBtn");
btn.disabled = true; btn.innerHTML = spinner;
try {
const r = await api("trial", { hwid: webId() });
setSubUrl(r.subscription_url);
toast("Пробный период активирован");
await home(true);
} catch (e) {
const msg = e.status === 409 ? "Пробный период уже использован." :
e.status === 429 ? "Слишком много попыток. Попробуйте позже." :
"Не удалось связаться с сервером. Проверьте интернет и попробуйте ещё раз.";
$("welcomeErr").innerHTML = `<p class="error">${msg}</p>`;
btn.remove();
}
}
async function addLink() {
const v = $("linkInput").value.trim();
if (!SUB_RE.test(v)) { $("welcomeErr").innerHTML = '<p class="error">Это не похоже на ссылку подписки Cosmo VPN.</p>'; return; }
try {
await api("tariffs", { subscription_url: v });
} catch (e) {
$("welcomeErr").innerHTML = `<p class="error">${e.status === 404 ? "Подписка не найдена." : "Не удалось проверить ссылку, попробуйте ещё раз."}</p>`;
return;
}
setSubUrl(v);
await home();
}
let offerCache = null;
async function home(justCreated = false) {
$("view").innerHTML = `<div class="loading">${spinner}</div>`;
let offer = null;
try { offer = await api("tariffs", who()); } catch (e) {
if (e.status === 404) { // the bot removes subscriptions some time after they expire
setSubUrl(null);
return start();
}
}
offerCache = offer;
const exp = offer && offer.expire_at ? new Date(offer.expire_at) : null;
const left = exp ? Math.ceil((exp - Date.now()) / 86400000) : null;
const expired = left !== null && left <= 0;
const farFuture = left !== null && left > 3650;
const statusLine = !offer ? "Не удалось загрузить данные подписки"
: expired ? "Подписка закончилась"
: farFuture ? "Подписка активна"
: `Осталось ${left} ${plural(left, "день", "дня", "дней")}`;
const happAdd = "happ://add/" + subUrl;
$("view").innerHTML = `
<h1>Cosmo VPN</h1>
<div class="card status ${expired ? "expired" : ""}">
<p class="muted" style="color:#e8d3f3">${offer && offer.current_tariff ? esc(offer.current_tariff) : "Подписка"}</p>
<div class="big">${statusLine}</div>
${exp && !farFuture ? `<p style="color:#e8d3f3">${expired ? "Закончилась" : "Действует до"} ${fmtDate(offer.expire_at)}</p>` : ""}
<span class="pill">Безлимитный трафик</span>
${offer && offer.current_devices ? `<span class="pill">Устройств: ${offer.current_devices}</span>` : ""}
</div>
<button class="btn" id="renewBtn">Купить / продлить подписку</button>
${installBanner()}
<div class="card">
<h2>Подключение</h2>
<p class="muted" style="font-size:14px">${justCreated ? "Подписка готова! " : ""}Добавьте подписку в приложение Happ и включите VPN в нём.</p>
<a class="btn" href="${esc(happAdd)}">Добавить в Happ и подключиться</a>
${isAndroid ? `<a class="btn outline" href="cosmovpn://install-sub?url=${encodeURIComponent(subUrl)}">Открыть в приложении Cosmo VPN</a>` : ""}
<a class="btn text" href="${isAndroid ? HAPP_ANDROID : HAPP_IOS}" target="_blank" rel="noopener">Нет Happ? Установить из ${isAndroid ? "Google Play" : "App Store"}</a>
<button class="btn text" id="copyBtn">Скопировать ссылку подписки</button>
<p class="linkbox">${esc(subUrl)}</p>
<p class="note">Сохраните ссылку: по ней подписку можно добавить в любой VPN-клиент или восстановить на другом устройстве.</p>
</div>
<div id="pushBox"></div>`;
bindInstall();
$("renewBtn").onclick = () => openSheet();
$("copyBtn").onclick = async () => {
try { await navigator.clipboard.writeText(subUrl); toast("Ссылка скопирована"); }
catch (_) { toast("Не удалось скопировать — выделите ссылку вручную"); }
};
renderPushBox();
}
function bindInstall() {
const b = $("installBtn");
if (b && deferredInstall) b.onclick = async () => { deferredInstall.prompt(); deferredInstall = null; b.parentElement.remove(); };
}
// ---------------------------------------------------------------- buy / renew sheet
const sheetState = { offer: null, selected: -1, devices: null, paying: false, error: null, loading: false };
function priceFor(t, devices) {
const opts = t.device_options || [];
if (!opts.length) return t.base_price_rub;
const base = Math.min(...opts);
const n = opts.includes(devices) ? devices : base;
if (n <= base) return t.base_price_rub;
const ov = (t.device_overrides || {})[String(n)];
return t.base_price_rub + (ov != null ? Number(ov) : (n - base) * (t.device_step_rub || 0));
}
const clampDevices = (t, d) => {
const opts = t.device_options || [];
if (!opts.length) return null;
return opts.includes(d) ? d : (t.default_devices ?? Math.min(...opts));
};
async function openSheet() {
$("scrim").classList.add("open"); $("sheet").classList.add("open");
$("scrim").onclick = closeSheet;
Object.assign(sheetState, { loading: true, error: null, paying: false });
renderSheet();
try {
const o = await api("tariffs", who());
sheetState.offer = o;
const ids = o.tariffs.map((t) => t.id);
if (!ids.includes(sheetState.selected)) sheetState.selected = (o.tariffs.find((t) => t.days === 30) || o.tariffs[0] || {}).id ?? -1;
if (sheetState.devices == null) sheetState.devices = (o.tariffs.find((t) => (t.device_options || []).length) || {}).default_devices ?? null;
} catch (e) {
sheetState.error = "load";
}
sheetState.loading = false;
renderSheet();
}
function closeSheet() { $("scrim").classList.remove("open"); $("sheet").classList.remove("open"); }
function tariffCard(t) {
const [name, period] = t.title.includes(":") ? [t.title.split(":")[0].trim(), t.title.split(":").slice(1).join(":").trim()] : ["", t.title];
const sel = t.id === sheetState.selected;
return `<button class="tariff ${sel ? "sel" : ""}" data-id="${t.id}">
<div class="period">${esc(period)}</div>${name ? `<div class="name">${esc(name)}</div>` : ""}
<div class="price">${priceFor(t, clampDevices(t, sheetState.devices))} ₽</div></button>`;
}
function renderSheet() {
const s = sheetState, o = s.offer;
const isNew = o ? o.is_new : !subUrl;
let body = "";
if (s.loading) body = `<div class="loading">${spinner}</div>`;
else if (s.error === "load") body = `<p>Не удалось связаться с сервисом оплаты. Проверьте интернет и попробуйте ещё раз.</p>
<button class="btn outline" id="retryBtn">Повторить</button>`;
else if (!o.renewable) body = `<p>Эту подписку нельзя продлить здесь. Откройте бота @cosmonetvpn_bot, чтобы выбрать тариф.</p>`;
else {
const standard = o.tariffs.filter((t) => !t.unlimited_devices), vip = o.tariffs.filter((t) => t.unlimited_devices);
const chosen = o.tariffs.find((t) => t.id === s.selected);
const ref = chosen && (chosen.device_options || []).length ? chosen : standard.find((t) => (t.device_options || []).length);
let stepper = "";
if (ref) {
const opts = ref.device_options, n = clampDevices(ref, s.devices);
stepper = `<div class="stepper"><span class="lbl">Устройств</span>
<button id="devMinus" ${s.paying || n <= Math.min(...opts) ? "disabled" : ""}>−</button>
<span class="n">${n}</span>
<button id="devPlus" ${s.paying || n >= Math.max(...opts) ? "disabled" : ""}>+</button></div>`;
}
const price = chosen ? priceFor(chosen, clampDevices(chosen, s.devices)) : null;
body = `${stepper}
${standard.length ? `<div class="grid">${standard.map(tariffCard).join("")}</div>` : ""}
${vip.length ? `<div class="section">VIP <span>· без лимита устройств</span></div><div class="grid">${vip.map(tariffCard).join("")}</div>` : ""}
${s.error === "pay" ? '<p class="error">Не удалось создать платёж. Попробуйте ещё раз через минуту.</p>' : ""}
<button class="btn" id="payBtn" ${!chosen || s.paying ? "disabled" : ""}>${s.paying ? spinner : price != null ? `Оплатить ${price} ₽` : "Выберите тариф"}</button>
<p class="note">Оплата картой или через СБП в ЮKassa. После оплаты вернитесь в Cosmo VPN — подписка ${isNew ? "появится" : "продлится"} автоматически.</p>`;
}
$("sheet").innerHTML = `<div class="grip"></div>
<h2>${isNew ? "Купить подписку" : "Продлить подписку"}</h2>
${o && o.expire_at ? `<p class="muted">Действует до ${fmtDate(o.expire_at)}</p>` : ""}
<div style="height:10px"></div>${body}`;
const retry = $("retryBtn"); if (retry) retry.onclick = openSheet;
document.querySelectorAll(".tariff").forEach((b) => (b.onclick = () => {
if (s.paying) return;
s.selected = Number(b.dataset.id); s.error = null; renderSheet();
}));
const minus = $("devMinus"), plus = $("devPlus");
const step = (dir) => {
const t = o.tariffs.find((x) => x.id === s.selected && (x.device_options || []).length) ||
o.tariffs.find((x) => !x.unlimited_devices && (x.device_options || []).length);
const opts = t.device_options, n = clampDevices(t, s.devices);
s.devices = dir < 0 ? [...opts].reverse().find((x) => x < n) ?? n : opts.find((x) => x > n) ?? n;
renderSheet();
};
if (minus) minus.onclick = () => step(-1);
if (plus) plus.onclick = () => step(1);
const pay = $("payBtn"); if (pay) pay.onclick = startPayment;
}
async function startPayment() {
const s = sheetState, t = s.offer.tariffs.find((x) => x.id === s.selected);
if (!t) return;
s.paying = true; s.error = null; renderSheet();
try {
const body = { ...who(), tariff_id: t.id, return_to: "web" };
const d = clampDevices(t, s.devices);
if (d != null) body.devices = d;
const r = await api("payments", body);
// Status must be asked with the same identity the payment was created with.
store.set("cosmo_pending", JSON.stringify({ id: r.payment_id, at: Date.now(), sub: subUrl || "" }));
location.href = r.confirmation_url;
} catch (e) {
s.paying = false; s.error = "pay"; renderSheet();
}
}
// ---------------------------------------------------------------- pending payment
let polling = false;
async function pollPending() {
if (polling) return;
let p = null;
try { p = JSON.parse(store.get("cosmo_pending") || "null"); } catch (_) {}
if (!p || !p.id) return;
if (Date.now() - p.at > PENDING_TTL) { store.set("cosmo_pending", null); return; }
polling = true;
const ident = p.sub ? { subscription_url: p.sub } : { hwid: webId() };
try {
for (let i = 0; i < 40 && document.visibilityState === "visible"; i++) {
let st = null;
try { st = await api("payments/status", { ...ident, payment_id: p.id }); } catch (_) {}
if (st && st.status === "renewed") {
store.set("cosmo_pending", null);
if (st.subscription_url && !subUrl) setSubUrl(st.subscription_url);
closeSheet();
toast(st.expire_at ? `Подписка оплачена до ${fmtDate(st.expire_at)}` : "Подписка оплачена");
await home(!!st.subscription_url);
return;
}
if (st && st.status === "canceled") { store.set("cosmo_pending", null); toast("Оплата отменена"); return; }
await new Promise((r) => setTimeout(r, 3000));
}
} finally { polling = false; }
}
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") pollPending(); });
// ---------------------------------------------------------------- push notifications
const pushSupported = "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
function renderPushBox() {
const box = $("pushBox");
if (!box) return;
if (!pushSupported) {
box.innerHTML = isIOS && !standalone
? '<p class="note center">Уведомления о подписке появятся, когда вы добавите Cosmo VPN на экран «Домой».</p>' : "";
return;
}
if (Notification.permission === "granted") { box.innerHTML = ""; refreshPushIdentity(); return; }
if (Notification.permission === "denied") {
box.innerHTML = '<p class="note center">Уведомления выключены в настройках устройства.</p>'; return;
}
box.innerHTML = `<div class="card"><h2>Уведомления</h2>
<p class="muted" style="font-size:14px">Напомним, когда подписка будет заканчиваться, и пришлём важные новости.</p>
<button class="btn outline" id="pushBtn">Включить уведомления</button></div>`;
$("pushBtn").onclick = enablePush;
}
async function enablePush() {
try {
const perm = await Notification.requestPermission();
if (perm !== "granted") { renderPushBox(); return; }
await refreshPushIdentity(true);
toast("Уведомления включены");
} catch (e) {
toast("Не удалось включить уведомления");
}
renderPushBox();
}
function b64ToBytes(b64) {
const pad = "=".repeat((4 - (b64.length % 4)) % 4);
const raw = atob((b64 + pad).replace(/-/g, "+").replace(/_/g, "/"));
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
}
// (Re)sends this browser's push subscription with the current identity (device id or subscription link).
async function refreshPushIdentity(create = false) {
if (!pushSupported || Notification.permission !== "granted") return;
try {
const reg = await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if (!sub && create) {
const { key } = await api("push/key");
sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToBytes(key) });
}
if (sub) await api("push/subscribe", { ...who(), subscription: sub.toJSON() });
} catch (e) {
console.warn("push", e);
}
}
// ---------------------------------------------------------------- boot
if ("serviceWorker" in navigator) navigator.serviceWorker.register("sw.js").catch(() => {});
start().then(pollPending);